Take the group headings out of the host grid, and drop onto a group card

A first group turned the wrap of host cards into an accordion: SidebarRows
interleaves a full-width fold-away heading — chevron, name, count — between the
cards, and in a grid that reads as a dropdown somebody left open. The desktop
grid binds VisibleHosts now. The headings and the fold stay for the phone, whose
list has no room for the row of group cards the desktop draws above the grid.

What a heading said, the card says: HostRowViewModel.GroupLabel, resolved once
per reload like the tag names, drawn as an accent chip and absent from a host in
no group — or in one that has been deleted, which is the same thing to look at.

What a heading also was is the drop target, and that moves to the group cards.
Two things go with it. A host dropped onto another host card used to be filed
beside it, which was legible while a heading named the band of cards it landed
in and is guesswork now; it is refused. And UNGROUPED was how a host was dragged
back out of a group; the way out is the picker in its own editor, which is the
one place "no group" can be said in words.

A drag held at either edge of the grid scrolls it. Without that the gesture only
works for whoever can see both ends of it: the group cards are the first thing in
the scroller, the host may be the fortieth card down, and a drag cannot use the
wheel. A step per drag event rather than a timer, so it follows the pointer and
stops when it stops.

The two heading-shaped tests are replaced. TheHostsGridHoldsCardsAndNoGroupHeadings
asserts the grid's contents rather than only measuring them, because a heading
that came back would lay out perfectly cleanly. TheGroupCardsAreWhatAcceptsADroppedHost
raises a real DragOver over both kinds of card and checks the effects and the
mark — the nearest a headless test gets to a gesture no headless test can
synthesise. manual-checks 3.1-3.2 and 7.6-7.9 follow.
This commit is contained in:
2026-08-03 15:49:58 +02:00
parent 1b7df47537
commit f9d08b738c
8 changed files with 432 additions and 147 deletions
+109 -35
View File
@@ -25,7 +25,7 @@ namespace DodoSSH.Client.App.Views;
internal sealed partial class HostsScreen : UserControl
{
/// <summary>
/// How a host travels from the card it was picked up on to the heading it is dropped on.
/// 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
@@ -43,6 +43,21 @@ internal sealed partial class HostsScreen : UserControl
/// </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
@@ -55,7 +70,7 @@ internal sealed partial class HostsScreen : UserControl
private Point origin;
/// <summary>The card or heading the pointer is currently over, while a drag is in flight.</summary>
/// <summary>The group card the pointer is currently over, while a drag is in flight.</summary>
private ListBoxItem? marked;
public HostsScreen()
@@ -78,9 +93,22 @@ internal sealed partial class HostsScreen : UserControl
HostGrid.PointerReleased += OnPointerReleased;
HostGrid.PointerCaptureLost += OnPointerCaptureLost;
DragDrop.AddDragOverHandler(HostGrid, OnDragOver);
DragDrop.AddDragLeaveHandler(HostGrid, OnDragLeave);
DragDrop.AddDropHandler(HostGrid, OnDrop);
// 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>
@@ -117,9 +145,9 @@ internal sealed partial class HostsScreen : UserControl
/// </remarks>
private void OnHostActivated(object? sender, TappedEventArgs e)
{
// Only over a card. A double-tap on a group heading folds it and unfolds it again, and must not also
// 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.
// 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);
@@ -137,9 +165,9 @@ internal sealed partial class HostsScreen : UserControl
/// against.
/// </para>
/// <para>
/// Cancelled outright over a group heading and over the space around the cards. Neither is a host, and a
/// menu offering Connect, Edit and Delete over one would be three buttons that either do nothing or act
/// on something else entirely.
/// 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)
@@ -233,9 +261,9 @@ internal sealed partial class HostsScreen : UserControl
/// Says whether what is under the pointer would take this host, and marks it if it would.
/// </summary>
/// <remarks>
/// A host over its own group's heading 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.
/// 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)
{
@@ -254,6 +282,60 @@ internal sealed partial class HostsScreen : UserControl
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
@@ -279,13 +361,12 @@ internal sealed partial class HostsScreen : UserControl
/// </summary>
/// <remarks>
/// <para>
/// A heading is the obvious target and a card is the useful one: dropping onto a machine files the
/// dragged host beside it, which means the whole band of cards under a heading is a target rather than
/// one line of text. The ungrouped heading is a target like any other, and it is how a host is taken out
/// of a group without opening the editor.
/// 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 grid already does with
/// 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>
@@ -293,25 +374,13 @@ internal sealed partial class HostsScreen : UserControl
private static DropTarget? Target(DragEventArgs e)
{
if (e.DataTransfer.TryGetValue(HostFormat) is not { } dragged
|| Container(e.Source) is not { } container)
|| Container(e.Source) is not { DataContext: HostGroupRowViewModel group } container
|| dragged.Host.GroupId == group.EntityId)
{
return null;
}
Guid? group = container.DataContext switch
{
SidebarGroupHeader header => header.GroupId,
HostRowViewModel row => row.Host.GroupId,
_ => null,
};
if (container.DataContext is not (SidebarGroupHeader or HostRowViewModel)
|| dragged.Host.GroupId == group)
{
return null;
}
return new DropTarget(dragged, group, container);
return new DropTarget(dragged, group.EntityId, container);
}
private void Mark(ListBoxItem container)
@@ -353,5 +422,10 @@ internal sealed partial class HostsScreen : UserControl
: null;
/// <summary>A drag in flight, and where it would land.</summary>
private sealed record DropTarget(HostRowViewModel Host, Guid? GroupId, ListBoxItem Container);
/// <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);
}