Public Access
Merge branch 'claude/windows-multiselect-support-37541c'
This commit is contained in:
@@ -647,6 +647,23 @@
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ A CARD IN THE CHOSEN SET, which is not the same thing as the selected one.
|
||||
|
||||
The list holds one selection — the card the drawer and CONNECT are about — and the set is what Ctrl,
|
||||
Shift and a band dragged across the grid build on top of it. Six ticked cards with one of them also
|
||||
selected is the ordinary shape, so the two marks have to be legible together and legible apart.
|
||||
|
||||
The fill and the tick on the card are the mark; the border is deliberately left to the rule above. A
|
||||
chosen card that also drew an accent outline would be indistinguishable from the selected one, and the
|
||||
question "which of these is the drawer showing" would have no answer. Declared after that rule anyway,
|
||||
because it sets the same Background and Avalonia settles two matching rules by declaration order —
|
||||
the trap this file records for Button.tab, Border.rowmark and the drop target below.
|
||||
-->
|
||||
<Style Selector="Border.tile.chosen">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
</Style>
|
||||
|
||||
<!-- The square a tile carries on its left: a group's mark, or a host's prompt. -->
|
||||
<Style Selector="Border.tileicon">
|
||||
<Setter Property="Width" Value="32" />
|
||||
|
||||
@@ -377,12 +377,164 @@
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="HOSTS"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
|
||||
<!--
|
||||
============ ◆ WHAT SAYS A SET IS UP ============
|
||||
Ctrl, Shift and a band dragged across the grid tick cards — see HostsScreen.axaml.cs — and
|
||||
this is the whole of the chrome that arrives with them: how many, the way out, and where the
|
||||
actions are. The phone raises a contextual action bar in the vault header's place for the
|
||||
same state; a window with room for a context menu does not need one, and a strip of eight
|
||||
buttons over the grid would be a second home for entries the menu already has.
|
||||
|
||||
◆ THE MENU IS WHERE THE ACTIONS ARE, and the sentence says so because there is no other way
|
||||
to find that out. Everything else on this screen can be reached by looking at it.
|
||||
|
||||
A Grid rather than a horizontal StackPanel, for the reason the host card carries at length: a
|
||||
horizontal StackPanel measures its children with infinite width, so the sentence would never
|
||||
learn it is short of room and would run out over the count at the far end instead of
|
||||
trimming. At the window's minimum with the drawer open there is not room for all of it.
|
||||
-->
|
||||
<Grid Grid.Column="1" Margin="12,0" ColumnDefinitions="Auto,Auto,*"
|
||||
IsVisible="{Binding IsChoosingHosts}">
|
||||
<Border Grid.Column="0" Classes="chip accent" Padding="6,1" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding ChosenHostsSummary}" FontSize="9.5" />
|
||||
</Border>
|
||||
<Button Grid.Column="1" Classes="flat" Margin="6,0,0,0" Padding="5,1" FontSize="9.5"
|
||||
Content="CLEAR" Command="{Binding ClearHostChoiceCommand}"
|
||||
ToolTip.Tip="Takes the ticks off every card · Esc, or a click on the space around them" />
|
||||
<TextBlock Grid.Column="2" Classes="hint" FontSize="10" Margin="8,0,0,0"
|
||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||
Text="right-click one of them for what can be done to all of them" />
|
||||
</Grid>
|
||||
|
||||
<!-- The collection's own count, so it follows both filters with no second number to keep in step. -->
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding VisibleHosts.Count}"
|
||||
FontSize="11" Foreground="{StaticResource TextFaint}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
============ ◆ WHAT THE SET'S MENU ASKED ============
|
||||
Three panels, at most one of them up, all three above the grid rather than over it — the same
|
||||
arrangement the GROUPS section uses and the phone's list uses, for the reason written there: a
|
||||
card laid over the cards hides the ticked ones, and which hosts are ticked is the information
|
||||
the question exists to give. The grid shortens instead.
|
||||
|
||||
Which of the three is showing is decided in the view model, because each disarms the other two
|
||||
on the way up. See MoveChosenHostsToVault, RegroupChosenHosts and DeleteChosenHosts.
|
||||
-->
|
||||
|
||||
<!--
|
||||
◆ MOVING OR COPYING THEM TO ANOTHER VAULT. One panel for both verbs, because they differ in one
|
||||
word and in the sentence under the picker; what varies is bound rather than duplicated. See
|
||||
VaultViewModel.ChosenHostVaultPanelTitle and its two siblings.
|
||||
|
||||
The sentence is not decoration: groups and tags are items of the vault being left, so neither
|
||||
can come along, and a host that arrived carrying either would point at something the
|
||||
destination does not contain.
|
||||
-->
|
||||
<Border Padding="10" Background="{StaticResource Panel}" CornerRadius="6"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||
IsVisible="{Binding IsSendingChosenHostsToAVault}">
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Classes="label"
|
||||
Text="{Binding ChosenHostVaultPanelTitle}" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Margin="8,0,0,0" FontSize="11"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Text="{Binding ChosenHostsSummary}" />
|
||||
</Grid>
|
||||
|
||||
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding ChosenHostVaultChoices}"
|
||||
SelectedItem="{Binding SelectedChosenHostVault}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||
<TextBlock Text="{Binding Display}" FontSize="12" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="{Binding ChosenHostVaultPanelNote}" />
|
||||
|
||||
<!--
|
||||
◆ THE KEY, and only for a move of exactly one host — which key to carry is a fact about one
|
||||
machine, and a copy that took it away would leave the original unable to connect. Unticked,
|
||||
because moving a key into a team's vault hands it to everybody holding that vault's key.
|
||||
See VaultViewModel.BringsTheChosenBindingAlong.
|
||||
-->
|
||||
<CheckBox IsVisible="{Binding HasAChosenBindingToBring}"
|
||||
IsChecked="{Binding BringsTheChosenBindingAlong}">
|
||||
<TextBlock FontSize="11.5" TextWrapping="Wrap"
|
||||
Text="{Binding ChosenBindingToBringQuestion}" />
|
||||
</CheckBox>
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
IsVisible="{Binding HasAChosenBindingToBring}"
|
||||
Text="{Binding ChosenBindingToBringNote}" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="{Binding ChosenHostVaultPanelVerb}"
|
||||
Command="{Binding ConfirmSendChosenHostsToAVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL"
|
||||
Command="{Binding CancelSendChosenHostsToAVaultCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ FILING THEM UNDER A GROUP, which is what dragging the set onto a group card does without the
|
||||
picker. Both exist for the reason the single-host gesture and the host editor both do: a
|
||||
gesture is unreachable without a pointer, and a card that has scrolled out of sight cannot be
|
||||
dragged onto.
|
||||
|
||||
One keychain's groups. A selection spanning two is refused before this panel is drawn at all —
|
||||
see VaultViewModel.RegroupChosenHosts, which is the same refusal the drop makes.
|
||||
-->
|
||||
<Border Padding="10" Background="{StaticResource Panel}" CornerRadius="6"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||
IsVisible="{Binding IsRegroupingChosenHosts}">
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="CHANGE GROUP" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Margin="8,0,0,0" FontSize="11"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Text="{Binding ChosenHostsSummary}" />
|
||||
</Grid>
|
||||
|
||||
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding ChosenHostGroupChoices}"
|
||||
SelectedItem="{Binding SelectedChosenHostGroup}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:GroupChoice">
|
||||
<TextBlock Text="{Binding Label}" FontSize="12" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="A group lends its port, its account and its key to every host filed under it that says nothing itself, so this can change what these machines dial. Nothing else about them moves." />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="FILE"
|
||||
Command="{Binding ConfirmRegroupChosenHostsCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL"
|
||||
Command="{Binding CancelRegroupChosenHostsCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ THE DELETION QUESTION, naming a count rather than a machine — which is the reason
|
||||
DeletionTarget.ChosenHosts exists: six copies of "delete prod-db?" is not a confirmation
|
||||
anybody reads. The shared card draws it, as it does for a group above and for one host in the
|
||||
drawer; see ConfirmDeleteCard.
|
||||
-->
|
||||
<Border Padding="10" Background="{StaticResource DangerWash}" CornerRadius="6"
|
||||
IsVisible="{Binding IsConfirmingChosenHostDeletion}">
|
||||
<views:ConfirmDeleteCard />
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
Named because it is where keyboard focus lands when the terminal gives it back, and because
|
||||
every gesture on it is wired in the code-behind.
|
||||
@@ -426,7 +578,8 @@
|
||||
-->
|
||||
<ListBox Classes="tiles" x:Name="HostGrid" Focusable="True"
|
||||
ItemsSource="{Binding VisibleHosts}"
|
||||
SelectedItem="{Binding SelectedSidebarRow}">
|
||||
SelectedItem="{Binding SelectedSidebarRow}"
|
||||
ToolTip.Tip="Double-press a card for a shell. Ctrl or Shift picks out several, and so does a band dragged across the space between them; right-click any of them for what can be done to the set.">
|
||||
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
|
||||
@@ -449,13 +602,53 @@
|
||||
already selected whatever was right-clicked, which is exactly what OpenHostPane falls back
|
||||
to.
|
||||
-->
|
||||
<!--
|
||||
◆ TWO MENUS IN ONE, AND WHICH OF THEM IS DRAWN IS WHETHER ANYTHING IS TICKED.
|
||||
|
||||
Once Ctrl, Shift or a band has ticked cards, this menu is about the set and nothing else —
|
||||
and it has to be, because the entries above act on the vault's *selection*, which is one
|
||||
host. A Delete… that asked about the card under the pointer while six sat ticked behind the
|
||||
menu is the exact mistake the whole of OnContextRequested exists to prevent, arrived at from
|
||||
the other direction. Right-clicking a card that is not in the set drops the set first, so
|
||||
these entries and the ones above are never both about something; see the code-behind.
|
||||
|
||||
This is the only home for the seven, which is why it says what each of them takes: a set from
|
||||
two keychains, a set with a host in it that a newer version wrote, and a set of one are three
|
||||
different answers. The three single-host entries are collapsed rather than greyed for the
|
||||
reason the phone's sheet collapses them — a terminal, an SFTP session and a form are each
|
||||
about one machine, and there is no reading of "edit these six".
|
||||
|
||||
Delete is under a separator, as it is above and as the phone's sheet has it.
|
||||
-->
|
||||
<ListBox.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Connect" Command="{Binding ConnectCommand}" />
|
||||
<MenuItem Header="Details…" Command="{Binding OpenHostPaneCommand}" />
|
||||
<MenuItem Header="Edit…" Command="{Binding EditSelectedHostCommand}" />
|
||||
<Separator />
|
||||
<MenuItem Header="Delete…" Command="{Binding DeleteHostCommand}" />
|
||||
<MenuItem Header="Connect" IsVisible="{Binding !IsChoosingHosts}"
|
||||
Command="{Binding ConnectCommand}" />
|
||||
<MenuItem Header="Details…" IsVisible="{Binding !IsChoosingHosts}"
|
||||
Command="{Binding OpenHostPaneCommand}" />
|
||||
<MenuItem Header="Edit…" IsVisible="{Binding !IsChoosingHosts}"
|
||||
Command="{Binding EditSelectedHostCommand}" />
|
||||
<Separator IsVisible="{Binding !IsChoosingHosts}" />
|
||||
<MenuItem Header="Delete…" IsVisible="{Binding !IsChoosingHosts}"
|
||||
Command="{Binding DeleteHostCommand}" />
|
||||
|
||||
<MenuItem Header="Connect" IsVisible="{Binding HasOneChosenHost}"
|
||||
Command="{Binding ConnectToChosenHostCommand}" />
|
||||
<MenuItem Header="Browse files" IsVisible="{Binding HasOneChosenHost}"
|
||||
Command="{Binding BrowseChosenHostCommand}" />
|
||||
<MenuItem Header="Edit…" IsVisible="{Binding HasOneChosenHost}"
|
||||
Command="{Binding EditChosenHostCommand}" />
|
||||
<MenuItem Header="Change group…" IsVisible="{Binding IsChoosingHosts}"
|
||||
Command="{Binding RegroupChosenHostsCommand}" />
|
||||
<MenuItem Header="Move to another vault…" IsVisible="{Binding IsChoosingHosts}"
|
||||
Command="{Binding MoveChosenHostsToVaultCommand}" />
|
||||
<MenuItem Header="Copy to another vault…" IsVisible="{Binding IsChoosingHosts}"
|
||||
Command="{Binding CopyChosenHostsToVaultCommand}" />
|
||||
<MenuItem Header="Duplicate" IsVisible="{Binding IsChoosingHosts}"
|
||||
Command="{Binding DuplicateChosenHostsCommand}" />
|
||||
<Separator IsVisible="{Binding IsChoosingHosts}" />
|
||||
<MenuItem Header="Delete…" IsVisible="{Binding IsChoosingHosts}"
|
||||
Command="{Binding DeleteChosenHostsCommand}" />
|
||||
</ContextMenu>
|
||||
</ListBox.ContextMenu>
|
||||
|
||||
@@ -466,7 +659,15 @@
|
||||
The address is on the tooltip and nowhere else on the card. It used to be the subtitle;
|
||||
see the note on the summary line below for what took its place and why it is not lost.
|
||||
-->
|
||||
<Border Classes="tile" ToolTip.Tip="{Binding Address}">
|
||||
<!--
|
||||
◆ Classes.chosen is the tick on the card, and it is a class rather than the list's own
|
||||
selection because they are two different things: the list holds one card, which is what
|
||||
the drawer and CONNECT are about, and the set is what Ctrl, Shift and the band build. A
|
||||
card can be in the set without being the selected one, which is what a run of six looks
|
||||
like. See VaultViewModel.ChooseHosts and Border.tile.chosen in App.axaml.
|
||||
-->
|
||||
<Border Classes="tile" Classes.chosen="{Binding IsChosen}"
|
||||
ToolTip.Tip="{Binding Address}">
|
||||
<!--
|
||||
A Panel so the pencil below can be drawn *over* the card rather than in a column of
|
||||
its own. A column would have cost the name 30 pixels of a 232-pixel tile — permanently,
|
||||
@@ -543,9 +744,21 @@
|
||||
now; grey means there is not one. It is deliberately not reachability — nothing
|
||||
here pings anything, and a dot that meant "up" would be a claim this application
|
||||
never checks.
|
||||
|
||||
◆ The tick sits beside it rather than in place of it, which is the arrangement
|
||||
the phone's row uses and for the same reason: the dot says whether a shell is
|
||||
open on this machine, and swapping it for the tick would make choosing a host
|
||||
hide a fact about it. It takes no width while it is collapsed, so a grid with
|
||||
nothing ticked is laid out exactly as it was.
|
||||
-->
|
||||
<Ellipse Grid.Column="2" Classes="dot" Classes.live="{Binding IsConnected}"
|
||||
VerticalAlignment="Top" Margin="6,3,0,0" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4"
|
||||
VerticalAlignment="Top" Margin="6,2,0,0">
|
||||
<TextBlock Text="✓" FontSize="11" IsVisible="{Binding IsChosen}"
|
||||
Foreground="{StaticResource AccentText}"
|
||||
VerticalAlignment="Center" />
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsConnected}"
|
||||
VerticalAlignment="Center" Margin="0,1,0,0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
@@ -625,6 +838,26 @@
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!--
|
||||
◆ THE BAND, drawn over the scroller rather than inside it.
|
||||
|
||||
It is a rectangle the pointer is dragging out right now, so it belongs in the viewport's coordinates
|
||||
and not in the scrolling content's: a band drawn inside the stack would slide away from the pointer
|
||||
the moment the grid scrolled under it. Same row as the ScrollViewer and after it, which is what puts
|
||||
it on top — a Grid cell stacks its children in declaration order.
|
||||
|
||||
IsHitTestVisible="False" is the whole of why the gesture works while it is up. The band is under the
|
||||
pointer by definition, and a rectangle that took the pointer would end the drag it is drawing.
|
||||
|
||||
Positioned by Margin from the top left rather than in a Canvas, because the two alignments below make
|
||||
the margin mean exactly "where the corner is"; see HostsScreen.axaml.cs, which is the only thing that
|
||||
writes to it.
|
||||
-->
|
||||
<Border Grid.Row="3" x:Name="Band" IsVisible="False" IsHitTestVisible="False"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Top"
|
||||
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
|
||||
BorderThickness="1" CornerRadius="2" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.VisualTree;
|
||||
@@ -17,6 +18,15 @@ namespace DodoSSH.Client.App.Views;
|
||||
/// right-clicked, and the drawer beside the grid has none of those. See <see cref="HostDrawer"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>There are two selections on this screen and they are not the same thing.</b> The <c>ListBox</c> holds
|
||||
/// one — the card the drawer, CONNECT and the ordinary menu are about — and the vault holds a <em>set</em>,
|
||||
/// which is what Ctrl, Shift and a band dragged across the grid build. The set is the phone's, built here by
|
||||
/// a pointer instead of by a long press; see <c>VaultViewModel.ChooseHosts</c> and Android's
|
||||
/// <c>HostsScreen</c>. Everything below that reads a modifier is about keeping the two from being confused
|
||||
/// for one another: a plain press drops the set, so no command ever runs while both are saying something
|
||||
/// different.
|
||||
/// </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.
|
||||
@@ -25,21 +35,30 @@ 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 group card it is dropped on.
|
||||
/// How hosts travel from the cards they were picked up on to the group card they are 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.
|
||||
/// <para>
|
||||
/// An in-process format carrying the rows themselves, rather than text carrying ids. The drag never
|
||||
/// leaves this window — there is nothing outside it that could accept a host — and the rows are what the
|
||||
/// drop needs: they know which vault each edit has to return to, which an id on its own does not.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A list rather than one row, because a drag that starts on a ticked card carries every ticked card.
|
||||
/// Moving whichever one the pointer happened to be holding and leaving the other five where they are is
|
||||
/// a gesture that quietly does a fraction of what it looks like it does.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static readonly DataFormat<HostRowViewModel> HostFormat =
|
||||
DataFormat.CreateInProcessFormat<HostRowViewModel>("dodossh-host-row");
|
||||
private static readonly DataFormat<IReadOnlyList<HostRowViewModel>> HostFormat =
|
||||
DataFormat.CreateInProcessFormat<IReadOnlyList<HostRowViewModel>>("dodossh-host-rows");
|
||||
|
||||
/// <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.
|
||||
/// those into a drag gesture the user never asked for. The band on the empty space between the cards
|
||||
/// uses it for the same reason — a click there means "nothing is chosen any more", and a rectangle that
|
||||
/// flashed up on every one of those would be a gesture reported where none happened.
|
||||
/// </remarks>
|
||||
private const double DragThreshold = 5;
|
||||
|
||||
@@ -66,13 +85,49 @@ internal sealed partial class HostsScreen : UserControl
|
||||
/// </remarks>
|
||||
private PointerPressedEventArgs? press;
|
||||
|
||||
private HostRowViewModel? pickedUp;
|
||||
/// <summary>What that press would carry: the ticked hosts, or the one card it landed on.</summary>
|
||||
private IReadOnlyList<HostRowViewModel> pickedUp = [];
|
||||
|
||||
private Point origin;
|
||||
|
||||
/// <summary>The group card the pointer is currently over, while a drag is in flight.</summary>
|
||||
private ListBoxItem? marked;
|
||||
|
||||
/// <summary>
|
||||
/// Where a Shift-click measures its run from: the last card pressed without one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Kept here rather than in the vault because it is a fact about the gesture rather than about the
|
||||
/// keychain — it is what the pointer last touched, and it means nothing to the phone or to any command.
|
||||
/// Null until something has been pressed, which is what a Shift-click into an untouched grid falls back
|
||||
/// to the selected card for.
|
||||
/// </remarks>
|
||||
private HostRowViewModel? anchor;
|
||||
|
||||
/// <summary>
|
||||
/// The ticked card a plain press landed on, which the release collapses the set onto.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ◆ <b>A plain press on a card that is already ticked cannot drop the set, because it may be the start
|
||||
/// of a drag of all of it.</b> So the decision is deferred to the release: if no drag began, the press
|
||||
/// was an ordinary click and means what a click always means here — this one card, and nothing else. If
|
||||
/// one did, <see cref="Forget"/> clears this on the way out and the set survives the journey.
|
||||
/// </remarks>
|
||||
private HostRowViewModel? collapse;
|
||||
|
||||
/// <summary>Whether a band is being dragged out over the grid right now.</summary>
|
||||
private bool banding;
|
||||
|
||||
/// <summary>The corner it was started from, in the scroller's own coordinates.</summary>
|
||||
private Point bandFrom;
|
||||
|
||||
/// <summary>Whether that band adds to the set rather than being the whole of it.</summary>
|
||||
/// <remarks>
|
||||
/// Read once, when the band starts, rather than per move. A modifier picked up halfway through a drag
|
||||
/// would change what the rectangle already crossed means, which is a selection nobody could predict.
|
||||
/// </remarks>
|
||||
private bool bandAdds;
|
||||
|
||||
public HostsScreen()
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -92,7 +147,9 @@ internal sealed partial class HostsScreen : UserControl
|
||||
|
||||
// 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.
|
||||
// what is selected, and the menu is about to open against it. It is also what lets a modifier click
|
||||
// stop the press dead — see OnPointerPressed, where a Ctrl-click must tick a card without the list
|
||||
// moving its own selection onto it.
|
||||
HostGrid.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
|
||||
HostGrid.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
|
||||
|
||||
@@ -105,6 +162,8 @@ internal sealed partial class HostsScreen : UserControl
|
||||
HostGrid.PointerReleased += OnPointerReleased;
|
||||
HostGrid.PointerCaptureLost += OnPointerCaptureLost;
|
||||
|
||||
WireTheBand();
|
||||
|
||||
// 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
|
||||
@@ -123,6 +182,31 @@ internal sealed partial class HostsScreen : UserControl
|
||||
DragDrop.AddDragOverHandler(Scroll, OnDragOverScroll);
|
||||
}
|
||||
|
||||
/// <summary>The band's own wiring, and the two keys that go with a set.</summary>
|
||||
/// <remarks>
|
||||
/// Split out of the constructor rather than sitting in it, and only because the constructor is at the
|
||||
/// length this repository's analyser allows for one. Everything here arrived together: it is the whole
|
||||
/// of choosing more than one card with a pointer.
|
||||
/// </remarks>
|
||||
private void WireTheBand()
|
||||
{
|
||||
// Esc and Ctrl+A, on the grid rather than on the window: both are ordinary editing keys that mean
|
||||
// something else everywhere else, and Ctrl+A in the find box above has to go on selecting the text
|
||||
// in it. The grid takes focus on a press, so the keys work from the moment anything has been
|
||||
// touched; see KeyboardTarget for the other half of who has the keyboard on this screen.
|
||||
HostGrid.KeyDown += OnGridKey;
|
||||
|
||||
// ◆ The band is the scroller's rather than the list's, because the space it is dragged out over is
|
||||
// mostly not the list's: a WrapPanel of cards is exactly as tall as its cards, so everything below
|
||||
// the last row — usually most of the screen — belongs to the stack around it. Tunnelled for the
|
||||
// reason the list's own press is, and it runs first, so it has to recognise a press on a card and
|
||||
// leave it alone.
|
||||
Scroll.AddHandler(PointerPressedEvent, OnBandPressed, RoutingStrategies.Tunnel);
|
||||
Scroll.PointerMoved += OnBandMoved;
|
||||
Scroll.PointerReleased += OnBandReleased;
|
||||
Scroll.PointerCaptureLost += OnBandCaptureLost;
|
||||
}
|
||||
|
||||
/// <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.
|
||||
@@ -187,15 +271,20 @@ internal sealed partial class HostsScreen : UserControl
|
||||
/// </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.
|
||||
/// The menu's commands 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>
|
||||
/// ◆ <b>A right click outside the ticked set drops the set, and one inside it keeps it.</b> That is the
|
||||
/// same rule as the plain press below, and it is what makes the two halves of the menu safe to draw from
|
||||
/// one markup: the entries about a set and the entries about a selection are never both meaningful, so
|
||||
/// Delete… can never be a question about the card under the pointer asked while six others sit ticked
|
||||
/// behind the menu.
|
||||
/// </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.
|
||||
/// Edit and Delete over it would be entries that either do nothing or act on something else entirely.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnContextRequested(object? sender, ContextRequestedEventArgs e)
|
||||
@@ -206,7 +295,15 @@ internal sealed partial class HostsScreen : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
vault.SelectedSidebarRow = row;
|
||||
if (vault.IsChoosingHosts && !row.IsChosen)
|
||||
{
|
||||
vault.ClearHostChoiceCommand.Execute(null);
|
||||
}
|
||||
|
||||
if (!vault.IsChoosingHosts)
|
||||
{
|
||||
vault.SelectedSidebarRow = row;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -236,23 +333,84 @@ internal sealed partial class HostsScreen : UserControl
|
||||
vault.SelectedGroup = row;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ticks cards, or remembers a press that may become a drag.
|
||||
/// </summary>
|
||||
/// <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.
|
||||
/// <para>
|
||||
/// ◆ <b>Ctrl and Shift are answered here and go no further.</b> Both mark the event handled, which is
|
||||
/// what stops the <c>ListBox</c> underneath from moving its own selection onto the card: a Ctrl-click
|
||||
/// that also selected would light the card it had just unticked, and the drawer would open on a machine
|
||||
/// the user was removing from a set. Handled on the way down is the only place that can be said —
|
||||
/// by the time the press bubbles the control has already answered it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A plain press drops the set</b>, unless it lands on a card already in it. That is the rule every
|
||||
/// file manager has and the reason the two selections on this screen can never disagree: after an
|
||||
/// ordinary click there is exactly one card in play. Landing on a ticked card defers the same decision
|
||||
/// to the release, because the press may be the start of a drag of the whole set; see
|
||||
/// <see cref="collapse"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing here refuses while the drawer's editor is open, and the phone's own <c>ChooseHost</c> does.
|
||||
/// The difference is real: there a sheet is over the list and the row under the finger is not what was
|
||||
/// aimed at, where the grid sits beside the editor in plain view. Ticking is free anyway — the seven
|
||||
/// things that can then be done to a set each refuse for themselves, and say so.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
press = null;
|
||||
pickedUp = null;
|
||||
Forget();
|
||||
|
||||
if (!e.GetCurrentPoint(HostGrid).Properties.IsLeftButtonPressed
|
||||
if (Vault is not { } vault
|
||||
|| !e.GetCurrentPoint(HostGrid).Properties.IsLeftButtonPressed
|
||||
|| RowUnder(e.Source) is not HostRowViewModel row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var control = e.KeyModifiers.HasFlag(KeyModifiers.Control);
|
||||
|
||||
if (e.KeyModifiers.HasFlag(KeyModifiers.Shift))
|
||||
{
|
||||
vault.ChooseHostRun(anchor ?? vault.SelectedHost, row, replacing: !control);
|
||||
|
||||
HostGrid.Focus();
|
||||
e.Handled = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (control)
|
||||
{
|
||||
vault.ToggleHostChoiceCommand.Execute(row);
|
||||
anchor = row;
|
||||
|
||||
HostGrid.Focus();
|
||||
e.Handled = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
anchor = row;
|
||||
|
||||
// Read before anything is cleared, since clearing is one of the two things it decides.
|
||||
if (row.IsChosen)
|
||||
{
|
||||
collapse = row;
|
||||
pickedUp = vault.ChosenHosts;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (vault.IsChoosingHosts)
|
||||
{
|
||||
vault.ClearHostChoiceCommand.Execute(null);
|
||||
}
|
||||
|
||||
pickedUp = [row];
|
||||
}
|
||||
|
||||
press = e;
|
||||
pickedUp = row;
|
||||
origin = e.GetPosition(HostGrid);
|
||||
}
|
||||
|
||||
@@ -263,7 +421,7 @@ internal sealed partial class HostsScreen : UserControl
|
||||
/// </remarks>
|
||||
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (press is not { } pressed || pickedUp is not { } row)
|
||||
if (press is not { } pressed || pickedUp.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -281,20 +439,232 @@ internal sealed partial class HostsScreen : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
var carried = pickedUp;
|
||||
|
||||
Forget();
|
||||
|
||||
_ = DragAsync(pressed, row);
|
||||
_ = DragAsync(pressed, carried);
|
||||
}
|
||||
|
||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) => Forget();
|
||||
/// <remarks>
|
||||
/// Where a press on a ticked card turns out to have been a click after all: no drag started, so it means
|
||||
/// what every other click on a card means. See <see cref="collapse"/>.
|
||||
/// </remarks>
|
||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (collapse is not null && Vault is { } vault)
|
||||
{
|
||||
vault.ClearHostChoiceCommand.Execute(null);
|
||||
}
|
||||
|
||||
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)
|
||||
/// <summary>
|
||||
/// Esc drops the set, and Ctrl+A is every card on the screen.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every card <em>on the screen</em>: <c>VisibleHosts</c>, which is one level of the tree with whatever
|
||||
/// is in the find box already applied. Ctrl+A over a filtered grid that quietly ticked forty machines
|
||||
/// including the thirty-two not being shown would be the worst possible input to Delete.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Handled only when they did something. Esc has other jobs on this window — it closes the palette, and
|
||||
/// it cancels the terminal's own things — and swallowing it here while nothing is ticked would take it
|
||||
/// away from whichever of those the user meant.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnGridKey(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (Vault is not { } vault)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Key is Key.Escape && vault.IsChoosingHosts)
|
||||
{
|
||||
vault.ClearHostChoiceCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasVisibleHosts)
|
||||
{
|
||||
vault.ChooseHosts(vault.VisibleHosts, replacing: true);
|
||||
anchor = vault.VisibleHosts[0];
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a band on the empty space between and below the cards.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Only below the top of the host grid, and only where there is nothing to press.</b> The scroller
|
||||
/// also holds the group cards, the trail and whichever panel the set's menu raised, and a rectangle
|
||||
/// dragged out from inside any of those would be a gesture aimed at hosts started on something that is
|
||||
/// not one. The ceiling is the grid's own top edge rather than a list of exclusions, so a panel opening
|
||||
/// above it moves the ceiling with no code here changing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The pointer is captured, because a band is nearly always dragged past the edge of the control it
|
||||
/// started in — down onto the status bar, or off the window entirely — and without capture the moves
|
||||
/// stop arriving and the release lands somewhere else, leaving a rectangle painted over the grid with
|
||||
/// nothing left to take it down.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing is ticked or unticked here. A press is not yet a band and may never become one; what a click
|
||||
/// on the empty space means is decided on the release. See <see cref="OnBandReleased"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnBandPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
EndBand();
|
||||
|
||||
if (Vault is null
|
||||
|| !e.GetCurrentPoint(Scroll).Properties.IsLeftButtonPressed
|
||||
|| TakesThePress(e.Source))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var at = e.GetPosition(Scroll);
|
||||
|
||||
if (at.Y < (HostGrid.TranslatePoint(default, Scroll)?.Y ?? double.MaxValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
banding = true;
|
||||
bandFrom = at;
|
||||
bandAdds = e.KeyModifiers.HasFlag(KeyModifiers.Control);
|
||||
|
||||
e.Pointer.Capture(Scroll);
|
||||
HostGrid.Focus();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The rectangle is drawn and the set is rewritten on every move, so what is ticked is what the band is
|
||||
/// over at that moment — including the cards it has just been pulled back off. A band that only ever
|
||||
/// added would make overshooting it unrecoverable without starting again. Ctrl is the exception and is
|
||||
/// the reason it is a mode read once: with it held the band adds to what was already ticked, which is how
|
||||
/// a second run is picked up without losing the first.
|
||||
/// </remarks>
|
||||
private void OnBandMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (!banding || Vault is not { } vault)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.GetCurrentPoint(Scroll).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
EndBand();
|
||||
return;
|
||||
}
|
||||
|
||||
var at = e.GetPosition(Scroll);
|
||||
|
||||
var box = new Rect(
|
||||
Math.Min(bandFrom.X, at.X),
|
||||
Math.Min(bandFrom.Y, at.Y),
|
||||
Math.Abs(at.X - bandFrom.X),
|
||||
Math.Abs(at.Y - bandFrom.Y));
|
||||
|
||||
if (box.Width < DragThreshold && box.Height < DragThreshold)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Band.Margin = new Thickness(box.X, box.Y, 0, 0);
|
||||
Band.Width = box.Width;
|
||||
Band.Height = box.Height;
|
||||
Band.IsVisible = true;
|
||||
|
||||
vault.ChooseHosts(CardsIn(box), replacing: !bandAdds);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A press and a release with no band between them is a click on the space around the cards, and that
|
||||
/// drops the set — the same thing a plain click on a card does, and the way out of selection mode for
|
||||
/// anybody who never finds Esc. Where a band <em>was</em> drawn the moves have already said what is
|
||||
/// ticked, and re-applying it here would only repeat the last one.
|
||||
/// </remarks>
|
||||
private void OnBandReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (!banding)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var drawn = Band.IsVisible;
|
||||
|
||||
EndBand();
|
||||
e.Pointer.Capture(null);
|
||||
|
||||
if (!drawn && Vault is { IsChoosingHosts: true } vault)
|
||||
{
|
||||
vault.ClearHostChoiceCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The band is taken down and nothing is decided. Capture is lost when a menu opens over the drag, when
|
||||
/// the window is deactivated, and when the platform simply takes it — none of which is the user saying
|
||||
/// what they wanted; leaving the rectangle up would be the only visible consequence.
|
||||
/// </remarks>
|
||||
private void OnBandCaptureLost(object? sender, PointerCaptureLostEventArgs e) => EndBand();
|
||||
|
||||
private void EndBand()
|
||||
{
|
||||
banding = false;
|
||||
Band.IsVisible = false;
|
||||
}
|
||||
|
||||
/// <summary>The hosts whose cards a rectangle over the scroller touches.</summary>
|
||||
/// <remarks>
|
||||
/// Touches rather than contains, which is what makes a band usable at all: cards are 232 pixels wide and
|
||||
/// a rectangle that had to swallow one whole would mean dragging across the full width of every card in
|
||||
/// the run, and would tick nothing at all for a band drawn down the middle of a column.
|
||||
/// </remarks>
|
||||
private List<HostRowViewModel> CardsIn(Rect box)
|
||||
{
|
||||
var hit = new List<HostRowViewModel>();
|
||||
|
||||
foreach (var container in HostGrid.GetRealizedContainers())
|
||||
{
|
||||
if (container.DataContext is HostRowViewModel row
|
||||
&& container.TranslatePoint(default, Scroll) is { } corner
|
||||
&& box.Intersects(new Rect(corner, container.Bounds.Size)))
|
||||
{
|
||||
hit.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
return hit;
|
||||
}
|
||||
|
||||
/// <summary>Whether what was pressed is something that answers a press itself.</summary>
|
||||
/// <remarks>
|
||||
/// Everything a band must not start from, asked as "is this inside one" rather than by hit-testing a
|
||||
/// rectangle: the cards, the scrollbar, and the controls on whichever panel is up. Missing the scrollbar
|
||||
/// is the one that would be felt every day — dragging the thumb would paint a band down the grid and
|
||||
/// tick everything it passed.
|
||||
/// </remarks>
|
||||
private static bool TakesThePress(object? source) => source is Visual visual
|
||||
&& visual.GetSelfAndVisualAncestors().Any(element =>
|
||||
element is ListBoxItem or ScrollBar or Button or ComboBox or TextBox);
|
||||
|
||||
/// <summary>Carries the picked-up hosts for as long as the user holds them.</summary>
|
||||
private async Task DragAsync(PointerPressedEventArgs pressed, IReadOnlyList<HostRowViewModel> rows)
|
||||
{
|
||||
var carried = new DataTransfer();
|
||||
carried.Add(DataTransferItem.Create(HostFormat, row));
|
||||
carried.Add(DataTransferItem.Create(HostFormat, rows));
|
||||
|
||||
try
|
||||
{
|
||||
@@ -313,12 +683,13 @@ internal sealed partial class HostsScreen : UserControl
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Says whether what is under the pointer would take this host, and marks it if it would.
|
||||
/// Says whether what is under the pointer would take these hosts, 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:
|
||||
/// A drag whose hosts are all already in the group under the pointer 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 set with even one host from somewhere else is accepted, because filing that one is a real move.
|
||||
/// </remarks>
|
||||
private void OnDragOver(object? sender, DragEventArgs e)
|
||||
{
|
||||
@@ -392,9 +763,17 @@ internal sealed partial class HostsScreen : UserControl
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two commands for the same gesture, and which one runs is how many cards were picked up. One host is
|
||||
/// <c>MoveHostToGroup</c>, which has always been what a drag does and which keeps that host selected. A
|
||||
/// set goes through <c>FileChosenHostsUnder</c>, which makes the refusals once rather than per host —
|
||||
/// a group belongs to one vault — and reports a count rather than forty status lines in a row.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnDrop(object? sender, DragEventArgs e)
|
||||
{
|
||||
@@ -408,7 +787,14 @@ internal sealed partial class HostsScreen : UserControl
|
||||
}
|
||||
|
||||
e.DragEffects = DragDropEffects.Move;
|
||||
vault.MoveHostToGroupCommand.Execute(new HostGroupMove(target.Host, target.GroupId));
|
||||
|
||||
if (target.Hosts.Count > 1)
|
||||
{
|
||||
vault.FileChosenHostsUnderCommand.Execute(target.Group);
|
||||
return;
|
||||
}
|
||||
|
||||
vault.MoveHostToGroupCommand.Execute(new HostGroupMove(target.Hosts[0], target.Group.EntityId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -428,14 +814,14 @@ internal sealed partial class HostsScreen : UserControl
|
||||
/// </remarks>
|
||||
private static DropTarget? Target(DragEventArgs e)
|
||||
{
|
||||
if (e.DataTransfer.TryGetValue(HostFormat) is not { } dragged
|
||||
if (e.DataTransfer.TryGetValue(HostFormat) is not { Count: > 0 } dragged
|
||||
|| Container(e.Source) is not { DataContext: HostGroupRowViewModel group } container
|
||||
|| dragged.Host.GroupId == group.EntityId)
|
||||
|| dragged.All(row => row.Host.GroupId == group.EntityId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DropTarget(dragged, group.EntityId, container);
|
||||
return new DropTarget(dragged, group, container);
|
||||
}
|
||||
|
||||
private void Mark(ListBoxItem container)
|
||||
@@ -461,7 +847,8 @@ internal sealed partial class HostsScreen : UserControl
|
||||
private void Forget()
|
||||
{
|
||||
press = null;
|
||||
pickedUp = null;
|
||||
pickedUp = [];
|
||||
collapse = null;
|
||||
}
|
||||
|
||||
/// <summary>The view model of the grid item an event happened on, if it happened on one.</summary>
|
||||
@@ -478,9 +865,11 @@ internal sealed partial class HostsScreen : UserControl
|
||||
|
||||
/// <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.
|
||||
/// The group is the card rather than its id, which it was while only one host could be dragged: the
|
||||
/// command that files a set takes the card, because the refusal it makes is about which vault the group
|
||||
/// is in. The way out of a group is still 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);
|
||||
private sealed record DropTarget(
|
||||
IReadOnlyList<HostRowViewModel> Hosts, HostGroupRowViewModel Group, ListBoxItem Container);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user