Public Access
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:
@@ -151,8 +151,17 @@
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============ THE GRID ============ -->
|
||||
<ScrollViewer Grid.Row="3" HorizontalScrollBarVisibility="Disabled">
|
||||
<!--
|
||||
============ THE GRID ============
|
||||
|
||||
A drop target that takes nothing itself. Everything the pointer can be over during a drag is inside
|
||||
this control, and it has to accept one so that the group cards at the top of it stay reachable from
|
||||
the bottom of a long grid: a host dragged off the fortieth card cannot be dropped on a group card
|
||||
that has scrolled out of sight, so a drag held at either edge scrolls this while it is in flight.
|
||||
See HostsScreen.axaml.cs.
|
||||
-->
|
||||
<ScrollViewer Grid.Row="3" x:Name="Scroll" HorizontalScrollBarVisibility="Disabled"
|
||||
DragDrop.AllowDrop="True">
|
||||
<StackPanel Margin="16,14" Spacing="16">
|
||||
|
||||
<!-- ============ GROUPS ============ -->
|
||||
@@ -198,10 +207,18 @@
|
||||
SelectedItem is GroupFilter and not SelectedGroup. The two are nearly the same thing here —
|
||||
GroupFilter assigns SelectedGroup — but only one of them may narrow the grid; see the property
|
||||
for why the phone's own use of SelectedGroup must not.
|
||||
|
||||
◆ THESE CARDS ARE THE DROP TARGET. A host card dragged onto one is filed under that group, and
|
||||
that is the whole of what a drag does on this screen. It used to be a heading inside the host
|
||||
grid, and the headings are gone — see the note on the host grid below. A group is a thing that
|
||||
is drawn once, at the top, where the eye already goes to narrow the grid; a target that is also
|
||||
the label of what you are aiming at needs no explaining. See the code-behind for the gesture.
|
||||
-->
|
||||
<ListBox Classes="tiles" x:Name="GroupGrid"
|
||||
DragDrop.AllowDrop="True"
|
||||
ItemsSource="{Binding Groups}"
|
||||
SelectedItem="{Binding GroupFilter}">
|
||||
SelectedItem="{Binding GroupFilter}"
|
||||
ToolTip.Tip="Press a group to show only its hosts. Drag a host card onto one to file it there.">
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
@@ -257,15 +274,31 @@
|
||||
focus off the terminal's child window and then calls Focus() on something that refuses it,
|
||||
leaving the window with nothing focused and the keystrokes going nowhere.
|
||||
|
||||
It binds SidebarRows rather than VisibleHosts, so the group headings are still in it. Two
|
||||
reasons, and neither is decoration: a grid showing every group at once has to say which cards
|
||||
belong to which, and a heading is the drop target that takes a host out of one group and into
|
||||
another. See VaultViewModel.RebuildSidebarRows for why a heading is a row rather than a
|
||||
container, and the code-behind for what a drag does with one.
|
||||
◆ CARDS ONLY, AND NO HEADINGS. It binds VisibleHosts rather than SidebarRows, which is the
|
||||
collection with the group headings folded into it. Those headings were a full-width fold-away
|
||||
bar between the cards — a chevron, a name and a count — and in a wrap of cards they read as a
|
||||
collapsed dropdown that had somehow been dealt the hosts: the moment a first group existed the
|
||||
grid stopped being a grid. Two controls saying "group" on one screen was one too many, and the
|
||||
cards above are the better of the two. SidebarRows and the fold are still the phone's, whose
|
||||
list has no room for a row of group cards; see Android's HostsScreen.
|
||||
|
||||
What a heading also was is the drop target, and that moved to the group cards rather than
|
||||
going away with it — see the note on them above, and the code-behind for the gesture. A host is
|
||||
taken back out of a group through its own editor, which is the one place "no group" can be said
|
||||
plainly.
|
||||
|
||||
Which group a card is in is on the card, as a chip. That is what the heading above it used to
|
||||
say, and one chip per card says it without an accordion.
|
||||
|
||||
SelectedItem is still SelectedSidebarRow and deliberately not SelectedHost, though every item
|
||||
here is now a host. That property swallows the null the ListBox writes back whenever the
|
||||
collection is rebuilt — which is every filter keystroke and every background sync — where
|
||||
binding the host selection directly would let that null through, and a null host selection
|
||||
closes the drawer and disarms a delete somebody is in the middle of confirming. See the
|
||||
property, and RebuildVisibleHosts on where the real clearing is done.
|
||||
-->
|
||||
<ListBox Classes="tiles" x:Name="HostGrid" Focusable="True"
|
||||
DragDrop.AllowDrop="True"
|
||||
ItemsSource="{Binding SidebarRows}"
|
||||
ItemsSource="{Binding VisibleHosts}"
|
||||
SelectedItem="{Binding SelectedSidebarRow}">
|
||||
|
||||
<ListBox.ItemsPanel>
|
||||
@@ -279,7 +312,8 @@
|
||||
one per card: a ContextMenu inside a DataTemplate would have the row for its data context,
|
||||
and the commands here are the vault's. The code-behind selects whatever was right-clicked
|
||||
before the menu opens, so the selection-based commands act on the card under the pointer,
|
||||
and cancels the menu outright over a group heading, which has no host to act on.
|
||||
and cancels the menu outright over the space around the cards, where there is no host to act
|
||||
on.
|
||||
-->
|
||||
<ListBox.ContextMenu>
|
||||
<ContextMenu>
|
||||
@@ -290,35 +324,9 @@
|
||||
</ContextMenu>
|
||||
</ListBox.ContextMenu>
|
||||
|
||||
<ListBox.DataTemplates>
|
||||
<ListBox.ItemTemplate>
|
||||
|
||||
<!--
|
||||
A heading, and it takes a whole row of the wrap rather than sitting in the flow as another
|
||||
card: it names the run of cards under it, and a heading the width of one card would read
|
||||
as the first of them. The stretch is what MinWidth buys — a WrapPanel gives a child the
|
||||
width it asks for and starts a new line when the line cannot hold it, so a child asking
|
||||
for more than one line holds is alone on its own.
|
||||
-->
|
||||
<DataTemplate DataType="vm:SidebarGroupHeader">
|
||||
<Button Classes="flat grouphead" MinWidth="640"
|
||||
Command="{Binding $parent[ListBox].((vm:VaultViewModel)DataContext).ToggleGroupCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
HorizontalContentAlignment="Stretch">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Chevron}"
|
||||
Foreground="{StaticResource TextFaint}" FontSize="9"
|
||||
VerticalAlignment="Center" Margin="0,0,6,0" />
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="{Binding Label}"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Count}" FontSize="11"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Margin="8,0,0,0" HorizontalAlignment="Left" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="vm:HostRowViewModel">
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
<Border Classes="tile">
|
||||
<StackPanel Spacing="6">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
@@ -389,6 +397,26 @@
|
||||
VerticalAlignment="Top" Margin="6,3,0,0" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
The group this host is filed under, and only when it is filed under one — which is
|
||||
the same rule the group cards above follow, and the reason a keychain nobody has
|
||||
filed anything in looks exactly as it did before groups existed.
|
||||
|
||||
This is what the fold-away heading over a run of cards used to say. A chip on the
|
||||
card says it per host instead, which is the shape the rest of this card already is
|
||||
and the shape a wrap of cards can carry: it survives the cards being reflowed, it
|
||||
needs no row of its own, and it is what changes when a card is dropped onto a group.
|
||||
|
||||
Accent rather than plain, so the one chip that is a *place* is not read as another
|
||||
tag. A group whose name the vault cannot resolve leaves this empty rather than
|
||||
printing an id, for the reason a tag does. See HostRowViewModel.GroupLabel.
|
||||
-->
|
||||
<Border Classes="chip accent" Padding="5,0" HorizontalAlignment="Left"
|
||||
IsVisible="{Binding HasGroup}">
|
||||
<TextBlock Text="{Binding GroupLabel}" FontSize="9.5"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The tags this host wears. Under the summary rather than beside it, because a host
|
||||
can wear several and the line above is three fixed-width facts where these are not.
|
||||
@@ -412,7 +440,7 @@
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
</ListBox.DataTemplates>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user