using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
///
/// The grid of groups and hosts, and everything on it that is a gesture rather than a binding.
///
///
///
/// All of this was HostSidebar'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 .
///
///
/// Its data context is the VaultViewModel, as 's is, so every binding in the
/// markup is a property of the vault. The window hands it over; see . The drawer
/// beside the grid inherits the same one.
///
///
internal sealed partial class HostsScreen : UserControl
{
///
/// How a host travels from the card it was picked up on to the group card it is dropped on.
///
///
/// 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.
///
private static readonly DataFormat HostFormat =
DataFormat.CreateInProcessFormat("dodossh-host-row");
/// How far the pointer has to travel before a press becomes a drag.
///
/// 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.
///
private const double DragThreshold = 5;
/// How close to the top or bottom of the grid a drag has to be held to scroll it.
///
/// 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.
///
private const double EdgeBand = 48;
/// How far one drag event inside that band moves the grid.
///
/// 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.
///
private const double EdgeStep = 24;
/// The press a drag would start from, or null once it has become one or been let go of.
///
/// Held because 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.
///
private PointerPressedEventArgs? press;
private HostRowViewModel? pickedUp;
private Point origin;
/// The group card the pointer is currently over, while a drag is in flight.
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);
}
///
/// 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.
///
private VaultViewModel? Vault => DataContext as VaultViewModel;
///
/// Where the keyboard should land when the terminal hands it back.
///
///
///
/// 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.
///
///
/// It has to be a control the keyboard can actually go to. Focus() 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
/// ListBox 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.
///
///
internal IInputElement KeyboardTarget =>
Vault is { HasVisibleHosts: true } ? HostGrid : HostFilter;
///
/// 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.
///
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);
}
}
///
/// Opens the group card that was double-clicked.
///
///
/// 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.
///
private void OnGroupActivated(object? sender, TappedEventArgs e)
{
if (Vault is { } vault && RowUnder(e.Source) is HostGroupRowViewModel group)
{
vault.OpenGroupCommand.Execute(group);
}
}
///
/// Points the menu at whatever was right-clicked.
///
///
///
/// 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.
///
///
/// 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.
///
///
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;
}
///
/// Points the group menu at whatever was right-clicked.
///
///
///
/// The host grid's rule, applied to the cards above it — see . What is
/// different is what an unaimed menu would have done: GroupTarget 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.
///
///
/// 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.
///
///
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;
}
///
/// 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.
///
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);
}
///
/// The drag is started from the remembered press once the pointer has travelled far enough — see
/// . 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.
///
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();
/// Carries one host for as long as the user holds it.
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();
}
}
///
/// Says whether what is under the pointer would take this host, and marks it if it would.
///
///
/// A host over the card of the group it is already in is refused, which is not pedantry:
/// DragDropEffects.None 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.
///
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();
///
/// Carries the grid under a drag that is over the cards rather than over a group.
///
///
///
/// Without this the gesture is only available to whoever can see both ends of it. 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.
///
///
/// 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.
///
///
/// 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.
///
///
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));
}
///
/// 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.
///
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));
}
///
/// Where a drag currently is, or null if it is over nothing that would take it.
///
///
///
/// 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.
///
///
/// 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 VaultViewModel.RebuildSidebarRows. That is decided in the command
/// rather than here, so the rule has one home.
///
///
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;
}
/// Lets go of a press that turned out not to be a drag, or has become one.
private void Forget()
{
press = null;
pickedUp = null;
}
/// The view model of the grid item an event happened on, if it happened on one.
private static object? RowUnder(object? source) => Container(source)?.DataContext;
///
/// 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.
///
private static ListBoxItem? Container(object? source) => source is Visual visual
? visual.FindAncestorOfType(includeSelf: true)
: null;
/// A drag in flight, and where it would land.
///
/// The group is a 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.
///
private sealed record DropTarget(HostRowViewModel Host, Guid GroupId, ListBoxItem Container);
}