Public Access
A pair of buttons sat at the end of the GROUPS heading, and the card's own right-click menu arrived later offering the same two things. Two controls for one job, and the buttons were the harder of the two to read: a button beside a heading has no card under a pointer to mean, so it had to work its subject out — the selected card, or failing that the group the trail ends with, which once a group is open is not a card on screen at all. The menu never has that problem, because opening it is what aims it. The menu is the whole of Edit and Delete on the desktop now. ShowsGroupActions went with the buttons, since hiding them was all it did. GroupTarget stays: the menu's two entries read it after the code-behind has selected whatever was right-clicked, and its fallback to the open group is what makes + NEW HOST open on the group somebody is standing in rather than on none. One case changes shape. Opening a group with nothing inside it folds the card grid away, so from in there nothing can be right-clicked — renaming that group means pressing the trail back one level, to where it has a card of its own. The buttons used to cover it through the fallback. The trail was already the way out of an empty group, and 3.2a says so now. The test that pressed EDIT through its binding is replaced by one holding that no button on the screen commands either of them, which is the failure worth catching: a button coming back is not a compile error, and it would draw itself in place, aimed at the group the trail ends with. What that test covered — a command that has to accept an empty parameter, and act on the card the pointer was on — the two menu tests beside it already do.
487 lines
21 KiB
C#
487 lines
21 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Input;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.VisualTree;
|
|
using DodoSSH.Client.Shell.ViewModels;
|
|
|
|
namespace DodoSSH.Client.App.Views;
|
|
|
|
/// <summary>
|
|
/// The grid of groups and hosts, and everything on it that is a gesture rather than a binding.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// All of this was <c>HostSidebar</c>'s until the host list became a grid of cards. It moved with the list
|
|
/// rather than staying with the editor: every handler here is about the thing that was clicked, dragged or
|
|
/// right-clicked, and the drawer beside the grid has none of those. See <see cref="HostDrawer"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// Its data context is the <c>VaultViewModel</c>, as <see cref="KeychainScreen"/>'s is, so every binding in the
|
|
/// markup is a property of the vault. The window hands it over; see <see cref="MainWindow"/>. The drawer
|
|
/// beside the grid inherits the same one.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed partial class HostsScreen : UserControl
|
|
{
|
|
/// <summary>
|
|
/// How a host travels from the card it was picked up on to the group card it is dropped on.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// An in-process format carrying the row itself, rather than text carrying an id. The drag never leaves
|
|
/// this window — there is nothing outside it that could accept a host — and the row is what the drop
|
|
/// needs: it knows which vault the edit has to return to, which an id on its own does not.
|
|
/// </remarks>
|
|
private static readonly DataFormat<HostRowViewModel> HostFormat =
|
|
DataFormat.CreateInProcessFormat<HostRowViewModel>("dodossh-host-row");
|
|
|
|
/// <summary>How far the pointer has to travel before a press becomes a drag.</summary>
|
|
/// <remarks>
|
|
/// A threshold, because a press on this grid is nearly always a click: selecting a host, or the first
|
|
/// half of the double-click that connects. Starting a drag on the press itself would turn every one of
|
|
/// those into a drag gesture the user never asked for.
|
|
/// </remarks>
|
|
private const double DragThreshold = 5;
|
|
|
|
/// <summary>How close to the top or bottom of the grid a drag has to be held to scroll it.</summary>
|
|
/// <remarks>
|
|
/// Deeper than a card's own margin, because the band has to be reachable while the pointer is still
|
|
/// carrying something the user is looking at — a band the width of a hairline would only be found by
|
|
/// accident, and only by somebody who did not need it.
|
|
/// </remarks>
|
|
private const double EdgeBand = 48;
|
|
|
|
/// <summary>How far one drag event inside that band moves the grid.</summary>
|
|
/// <remarks>
|
|
/// Roughly a third of a card, so a pointer moving inside the band travels the grid at about the speed it
|
|
/// is moving. A step of a whole card would jump the target out from under the pointer between two events.
|
|
/// </remarks>
|
|
private const double EdgeStep = 24;
|
|
|
|
/// <summary>The press a drag would start from, or null once it has become one or been let go of.</summary>
|
|
/// <remarks>
|
|
/// Held because <see cref="DragDrop.DoDragDropAsync"/> takes the press rather than the movement: the
|
|
/// gesture belongs to the pointer that went down, and the platform needs that event to hand the drag
|
|
/// over to the operating system.
|
|
/// </remarks>
|
|
private PointerPressedEventArgs? press;
|
|
|
|
private HostRowViewModel? pickedUp;
|
|
|
|
private Point origin;
|
|
|
|
/// <summary>The group card the pointer is currently over, while a drag is in flight.</summary>
|
|
private ListBoxItem? marked;
|
|
|
|
public HostsScreen()
|
|
{
|
|
InitializeComponent();
|
|
|
|
// Wired here rather than in the markup because it is a gesture rather than a binding, which is how
|
|
// the transfers screen opens a directory too. Double-clicking a machine to get a shell on it is what
|
|
// every other client of this kind does, and CONNECT stays: it is the one in the drawer with the
|
|
// password box above it, and a host that asks for a password still needs it typed first.
|
|
HostGrid.DoubleTapped += OnHostActivated;
|
|
|
|
// And a group card opens the group, on the same gesture, for the same reason: going inside
|
|
// something by double-clicking it is what the transfers screen's directories do and what every file
|
|
// manager does. One click used to open a group, which made the card that names a group and the
|
|
// control that narrows the grid to it the same press — so there was no way to select a group in
|
|
// order to rename it without also losing sight of every host outside it.
|
|
GroupGrid.DoubleTapped += OnGroupActivated;
|
|
|
|
// Tunnelled, so the card under the pointer is read before the ListBox has answered the press itself.
|
|
// Bubbling would work for the drag but not for the menu: by then the control has already decided
|
|
// what is selected, and the menu is about to open against it.
|
|
HostGrid.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
|
|
HostGrid.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
|
|
|
|
// And the group cards answer a right click the same way, for the same reason: their menu's commands
|
|
// read the vault's group selection, and without this they would act on whichever card was selected
|
|
// before — or, with none, on the group the trail ends with, which is not on screen at all.
|
|
GroupGrid.AddHandler(ContextRequestedEvent, OnGroupContextRequested, RoutingStrategies.Tunnel);
|
|
|
|
HostGrid.PointerMoved += OnPointerMoved;
|
|
HostGrid.PointerReleased += OnPointerReleased;
|
|
HostGrid.PointerCaptureLost += OnPointerCaptureLost;
|
|
|
|
// The host grid is where a drag starts and the group cards are where it lands. They used to be the
|
|
// same control: the target was a heading among the cards, and with the headings gone the group cards
|
|
// are the only thing on this screen that names a group. A card dropped onto another card is refused
|
|
// rather than filed beside it — in a grid with no headings there is nothing to say which group that
|
|
// would be, and a gesture whose result you cannot see before you let go is one that files machines
|
|
// somewhere the user did not intend.
|
|
DragDrop.AddDragOverHandler(GroupGrid, OnDragOver);
|
|
DragDrop.AddDragLeaveHandler(GroupGrid, OnDragLeave);
|
|
DragDrop.AddDropHandler(GroupGrid, OnDrop);
|
|
|
|
// Everywhere else the pointer can be during a drag, and it is a handler rather than the absence of
|
|
// one because AllowDrop is an inherited property: it is set on the scroller so that a drag anywhere
|
|
// over the grid is reported at all, and that makes every card inside it a drop target as far as the
|
|
// platform is concerned. This is where all of them but a group card are turned down — and where a
|
|
// drag held at the top or bottom edge pulls the grid towards the target.
|
|
DragDrop.AddDragOverHandler(Scroll, OnDragOverScroll);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Null before the window has handed one over, and while the previewer is showing this control with no
|
|
/// data context at all. Every handler below checks rather than assuming.
|
|
/// </remarks>
|
|
private VaultViewModel? Vault => DataContext as VaultViewModel;
|
|
|
|
/// <summary>
|
|
/// Where the keyboard should land when the terminal hands it back.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Exposed as a property rather than left for the window to find by name, because the name is inside
|
|
/// this control's template and the window cannot see it.
|
|
/// </para>
|
|
/// <para>
|
|
/// It has to be a control the keyboard can actually go to. <c>Focus()</c> on a collapsed control is
|
|
/// measurably a no-op and is not replayed when the control is revealed, so handing the keyboard to
|
|
/// something that is not there would swallow it: the terminal would let go and nothing would take it.
|
|
/// The grid no longer folds away as the sidebar's list could, but an empty grid is still a
|
|
/// <c>ListBox</c> with no item to take focus — and an empty grid is exactly what a filter that matches
|
|
/// nothing produces, which is a state somebody typing is very likely to be in. The find box is the
|
|
/// answer then, and it is a good one: it is where they were typing.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal IInputElement KeyboardTarget =>
|
|
Vault is { HasVisibleHosts: true } ? HostGrid : HostFilter;
|
|
|
|
/// <remarks>
|
|
/// Fire-and-forget, as the transfers screen's is: the command reports its own failures onto the status
|
|
/// line — an unknown host key, a refused password — and awaiting it here would mean an event handler
|
|
/// returning a task nothing observes.
|
|
/// </remarks>
|
|
private void OnHostActivated(object? sender, TappedEventArgs e)
|
|
{
|
|
// Only over a card. A double-tap on the space around them must not connect to whichever host was
|
|
// selected before — which is what an unguarded handler would do, on a machine the user is not even
|
|
// pointing at.
|
|
if (Vault is { } vault && RowUnder(e.Source) is HostRowViewModel)
|
|
{
|
|
_ = vault.ConnectCommand.ExecuteAsync(null);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens the group card that was double-clicked.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Guarded over the space around the cards exactly as the host grid's is, and it is the same mistake
|
|
/// being guarded against: an unguarded handler would open whichever group happened to be selected when
|
|
/// somebody double-clicked the gap beside it, throwing every host outside that group off the screen.
|
|
/// </remarks>
|
|
private void OnGroupActivated(object? sender, TappedEventArgs e)
|
|
{
|
|
if (Vault is { } vault && RowUnder(e.Source) is HostGroupRowViewModel group)
|
|
{
|
|
vault.OpenGroupCommand.Execute(group);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Points the menu at whatever was right-clicked.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The menu's three commands all read the vault's host selection, and a right click does not move it —
|
|
/// which would mean a menu that quietly acted on whichever host happened to be selected instead of the
|
|
/// one under the pointer. Deleting the wrong machine is the version of that mistake worth designing
|
|
/// against.
|
|
/// </para>
|
|
/// <para>
|
|
/// Cancelled outright over the space around the cards. That is not a host, and a menu offering Connect,
|
|
/// Edit and Delete over it would be three buttons that either do nothing or act on something else
|
|
/// entirely.
|
|
/// </para>
|
|
/// </remarks>
|
|
private void OnContextRequested(object? sender, ContextRequestedEventArgs e)
|
|
{
|
|
if (Vault is not { } vault || RowUnder(e.Source) is not HostRowViewModel row)
|
|
{
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
|
|
vault.SelectedSidebarRow = row;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Points the group menu at whatever was right-clicked.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The host grid's rule, applied to the cards above it — see <see cref="OnContextRequested"/>. What is
|
|
/// different is what an unaimed menu would have done: <c>GroupTarget</c> falls back to the open group
|
|
/// when no card is selected, so Edit and Delete over a card would have been offered about the group whose
|
|
/// contents are showing rather than the one the pointer is on. This menu is the only way to either of
|
|
/// them now, so aiming it is the whole of aiming them.
|
|
/// </para>
|
|
/// <para>
|
|
/// Cancelled outright over the space around the cards, as the host grid's is. That is not a group, and
|
|
/// the fallback is exactly what would make the menu look like it worked there.
|
|
/// </para>
|
|
/// </remarks>
|
|
private void OnGroupContextRequested(object? sender, ContextRequestedEventArgs e)
|
|
{
|
|
if (Vault is not { } vault || RowUnder(e.Source) is not HostGroupRowViewModel row)
|
|
{
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
|
|
vault.SelectedGroup = row;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Remembered rather than acted on. Whether this press is a click or the start of a drag is not known
|
|
/// until the pointer moves, so this is the point at which both are still possible.
|
|
/// </remarks>
|
|
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
|
{
|
|
press = null;
|
|
pickedUp = null;
|
|
|
|
if (!e.GetCurrentPoint(HostGrid).Properties.IsLeftButtonPressed
|
|
|| RowUnder(e.Source) is not HostRowViewModel row)
|
|
{
|
|
return;
|
|
}
|
|
|
|
press = e;
|
|
pickedUp = row;
|
|
origin = e.GetPosition(HostGrid);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The drag is started from the remembered press once the pointer has travelled far enough — see
|
|
/// <see cref="DragThreshold"/>. Fire-and-forget, because the drag loop runs for as long as the user holds
|
|
/// the button and an event handler cannot wait on that; what happens after it is only clearing the mark.
|
|
/// </remarks>
|
|
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
|
{
|
|
if (press is not { } pressed || pickedUp is not { } row)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!e.GetCurrentPoint(HostGrid).Properties.IsLeftButtonPressed)
|
|
{
|
|
Forget();
|
|
return;
|
|
}
|
|
|
|
var moved = e.GetPosition(HostGrid) - origin;
|
|
|
|
if (Math.Abs(moved.X) < DragThreshold && Math.Abs(moved.Y) < DragThreshold)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Forget();
|
|
|
|
_ = DragAsync(pressed, row);
|
|
}
|
|
|
|
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) => Forget();
|
|
|
|
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) => Forget();
|
|
|
|
/// <summary>Carries one host for as long as the user holds it.</summary>
|
|
private async Task DragAsync(PointerPressedEventArgs pressed, HostRowViewModel row)
|
|
{
|
|
var carried = new DataTransfer();
|
|
carried.Add(DataTransferItem.Create(HostFormat, row));
|
|
|
|
try
|
|
{
|
|
// ConfigureAwait(true): what follows touches the grid's own containers, and those are the UI
|
|
// thread's.
|
|
await DragDrop
|
|
.DoDragDropAsync(pressed, carried, DragDropEffects.Move)
|
|
.ConfigureAwait(true);
|
|
}
|
|
finally
|
|
{
|
|
// Whatever the drop did or did not do. A mark left behind would be a card that looks like a
|
|
// target for a drag that ended somewhere else entirely.
|
|
Unmark();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Says whether what is under the pointer would take this host, and marks it if it would.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A host over the card of the group it is already in is refused, which is not pedantry:
|
|
/// <c>DragDropEffects.None</c> is what turns the cursor into the "no" one, and a drag that looks like it
|
|
/// would do something and then does nothing is worse than one that says so while it is still in the air.
|
|
/// </remarks>
|
|
private void OnDragOver(object? sender, DragEventArgs e)
|
|
{
|
|
e.Handled = true;
|
|
|
|
if (Target(e) is not { } target)
|
|
{
|
|
e.DragEffects = DragDropEffects.None;
|
|
Unmark();
|
|
return;
|
|
}
|
|
|
|
e.DragEffects = DragDropEffects.Move;
|
|
Mark(target.Container);
|
|
}
|
|
|
|
private void OnDragLeave(object? sender, DragEventArgs e) => Unmark();
|
|
|
|
/// <summary>
|
|
/// Carries the grid under a drag that is over the cards rather than over a group.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Without this the gesture is only available to whoever can see both ends of it.</b> The group cards
|
|
/// are the first thing in the scrolling stack and the host being filed may be the fortieth card down, and
|
|
/// a drag cannot use the wheel — the pointer button is held. So a drag held near the top edge pulls the
|
|
/// grid down towards the target, which is what every file manager does with a drag near the edge of a
|
|
/// list.
|
|
/// </para>
|
|
/// <para>
|
|
/// A step per event rather than a timer, and that is a real limit rather than a simplification: a
|
|
/// stationary pointer receives no drag events on any platform this runs on, so the scroll follows the
|
|
/// pointer moving inside the band and stops when it stops. A timer would scroll on its own and would then
|
|
/// need cancelling on the drop, on the leave, and on the drag that ends outside the window entirely.
|
|
/// </para>
|
|
/// <para>
|
|
/// Reached only when the group cards did not handle the event first, which is what makes refusing the
|
|
/// drop here correct: the space around the cards is not a target, and saying so keeps the "no" cursor on
|
|
/// everything that is not a group.
|
|
/// </para>
|
|
/// </remarks>
|
|
private void OnDragOverScroll(object? sender, DragEventArgs e)
|
|
{
|
|
if (e.DataTransfer.TryGetValue(HostFormat) is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
e.Handled = true;
|
|
e.DragEffects = DragDropEffects.None;
|
|
Unmark();
|
|
|
|
var at = e.GetPosition(Scroll).Y;
|
|
var height = Scroll.Bounds.Height;
|
|
|
|
var step = at switch
|
|
{
|
|
_ when at < EdgeBand => -EdgeStep,
|
|
_ when at > height - EdgeBand => EdgeStep,
|
|
_ => 0,
|
|
};
|
|
|
|
if (step == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var furthest = Math.Max(0, Scroll.Extent.Height - Scroll.Viewport.Height);
|
|
|
|
Scroll.Offset = Scroll.Offset.WithY(Math.Clamp(Scroll.Offset.Y + step, 0, furthest));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Fire-and-forget, like every other command this control runs: the move writes to the vault and reports
|
|
/// itself onto the status line, and a drop handler that awaited it would be an event handler returning a
|
|
/// task nothing observes.
|
|
/// </remarks>
|
|
private void OnDrop(object? sender, DragEventArgs e)
|
|
{
|
|
e.Handled = true;
|
|
Unmark();
|
|
|
|
if (Vault is not { } vault || Target(e) is not { } target)
|
|
{
|
|
e.DragEffects = DragDropEffects.None;
|
|
return;
|
|
}
|
|
|
|
e.DragEffects = DragDropEffects.Move;
|
|
vault.MoveHostToGroupCommand.Execute(new HostGroupMove(target.Host, target.GroupId));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Where a drag currently is, or null if it is over nothing that would take it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// One kind of target: a group card. It is the control that already answers "which group", it is drawn
|
|
/// at the top of the screen where a drag can reach it from anywhere in the grid, and what it does when
|
|
/// dropped on is what it says on it. The space around the cards takes nothing.
|
|
/// </para>
|
|
/// <para>
|
|
/// A group the vault no longer has is read as no group at all, which is what the list already does with
|
|
/// a dangling reference — see <c>VaultViewModel.RebuildSidebarRows</c>. That is decided in the command
|
|
/// rather than here, so the rule has one home.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static DropTarget? Target(DragEventArgs e)
|
|
{
|
|
if (e.DataTransfer.TryGetValue(HostFormat) is not { } dragged
|
|
|| Container(e.Source) is not { DataContext: HostGroupRowViewModel group } container
|
|
|| dragged.Host.GroupId == group.EntityId)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new DropTarget(dragged, group.EntityId, container);
|
|
}
|
|
|
|
private void Mark(ListBoxItem container)
|
|
{
|
|
if (ReferenceEquals(marked, container))
|
|
{
|
|
return;
|
|
}
|
|
|
|
Unmark();
|
|
|
|
marked = container;
|
|
marked.Classes.Add("droptarget");
|
|
}
|
|
|
|
private void Unmark()
|
|
{
|
|
marked?.Classes.Remove("droptarget");
|
|
marked = null;
|
|
}
|
|
|
|
/// <summary>Lets go of a press that turned out not to be a drag, or has become one.</summary>
|
|
private void Forget()
|
|
{
|
|
press = null;
|
|
pickedUp = null;
|
|
}
|
|
|
|
/// <summary>The view model of the grid item an event happened on, if it happened on one.</summary>
|
|
private static object? RowUnder(object? source) => Container(source)?.DataContext;
|
|
|
|
/// <remarks>
|
|
/// Walks up from whatever was actually hit — a text block, a border, the card's own grid — because that
|
|
/// is what an event's source is. Anything not inside an item, which is the space around the cards,
|
|
/// yields null.
|
|
/// </remarks>
|
|
private static ListBoxItem? Container(object? source) => source is Visual visual
|
|
? visual.FindAncestorOfType<ListBoxItem>(includeSelf: true)
|
|
: null;
|
|
|
|
/// <summary>A drag in flight, and where it would land.</summary>
|
|
/// <remarks>
|
|
/// The group is a <see cref="Guid"/> rather than a nullable one, which it was while the ungrouped
|
|
/// heading was also a target. Every target is now a group card and every group card has an id; the way
|
|
/// out of a group is the host's own editor, which is the one place "no group" can be said in words.
|
|
/// </remarks>
|
|
private sealed record DropTarget(HostRowViewModel Host, Guid GroupId, ListBoxItem Container);
|
|
}
|