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); }