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);
|
||||
}
|
||||
|
||||
@@ -2384,7 +2384,13 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
internal bool ShowsAddButton => !AnEditorIsOpen && !IsChoosingHosts && !AChosenHostPanelIsOpen;
|
||||
|
||||
// ---- ◆ The phone's chosen hosts ----
|
||||
// ---- ◆ The chosen hosts ----
|
||||
//
|
||||
// ◆ BOTH HEADS, AND IT WAS THE PHONE'S ALONE. The set below is unchanged by that: what a head brings is
|
||||
// the gesture that fills it and the furniture that acts on it. The phone has a long press, a contextual
|
||||
// action bar and a sheet of seven entries; the desktop has Ctrl, Shift, a band dragged over the grid and
|
||||
// one context menu — see HostsScreen.axaml.cs there, and ChooseHosts below, which is the entry those
|
||||
// gestures use. Everything about *which hosts* and *what happens to them* is here, once.
|
||||
//
|
||||
// THE CONNECT BAR WAS HERE, AND WHAT REPLACED IT IS A SELECTION RATHER THAN A PANEL.
|
||||
//
|
||||
@@ -2418,13 +2424,33 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private readonly HashSet<Guid> chosenHostIds = [];
|
||||
|
||||
/// <summary>Which hosts the deletion question on screen is about.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The question names a count and the run that answers it reads the set again, so the two have to be
|
||||
/// the same set.</b> Nothing kept them together: the panel is drawn <em>above</em> the list on both heads
|
||||
/// rather than over it — deliberately, so the ticked rows can be seen while the question is answered —
|
||||
/// which leaves every one of them still able to be ticked and unticked. One more tick between "Delete
|
||||
/// these 6 hosts?" and pressing DELETE deleted seven, and the desktop's Ctrl-click and band made that a
|
||||
/// second's work rather than a deliberate act.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Compared rather than frozen, and the question is dropped rather than the set: what somebody has just
|
||||
/// chosen is what they meant, and a question they have already read is not. It also covers the case
|
||||
/// nobody performs — a colleague's deletion arriving mid-question and shrinking the set under it. Ids,
|
||||
/// for the reason <see cref="chosenHostIds"/> holds ids.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private readonly HashSet<Guid> deletionAskedAbout = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether the phone is in selection mode: a long press has chosen at least one host.
|
||||
/// Whether at least one host is ticked: a long press on the phone, a Ctrl-click or a band on the desktop.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Derived from the set being non-empty rather than being a flag beside it, so the mode and its contents
|
||||
/// cannot disagree. Unticking the last host leaves selection mode, which is what every Android list does
|
||||
/// and is the second way out of it — the other being the cross at the left of the bar.
|
||||
/// cannot disagree. Unticking the last host leaves selection mode, which is what every list of either
|
||||
/// kind does and is the second way out of it — the others being the cross at the left of the phone's bar,
|
||||
/// and CLEAR, Esc or a plain click on the desktop.
|
||||
/// </remarks>
|
||||
internal bool IsChoosingHosts => chosenHostIds.Count > 0;
|
||||
|
||||
@@ -2439,6 +2465,16 @@ internal sealed partial class VaultViewModel(
|
||||
internal string ChosenHostsLabel =>
|
||||
ChosenHostCount.ToString(CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>The same count with the word on it, which is what the desktop prints.</summary>
|
||||
/// <remarks>
|
||||
/// The bare number above works on the phone because it sits in a bar that is the whole top of the
|
||||
/// screen and can mean nothing else. The desktop has no such bar — the ticks arrive beside a HOSTS
|
||||
/// heading that already has a count of its own at the far end of the same row — so a second bare number
|
||||
/// there would be two numbers about two different things, side by side, neither of them labelled.
|
||||
/// </remarks>
|
||||
internal string ChosenHostsSummary =>
|
||||
$"{ChosenHostCount.ToString(CultureInfo.CurrentCulture)} chosen";
|
||||
|
||||
/// <summary>The chosen hosts, as the rows currently in the list.</summary>
|
||||
/// <remarks>
|
||||
/// Rebuilt per read rather than kept, because the rows it names are replaced on every reload and this is
|
||||
@@ -5356,11 +5392,11 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or removes one host, which is what a tap means once the bar is up.
|
||||
/// Adds or removes one host: a tap once the phone's bar is up, and a Ctrl-click on the desktop.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Taking the last tick off leaves selection mode, which is what every Android list does and is the
|
||||
/// second way out of it — the cross at the left of the bar being the first. It goes through
|
||||
/// Taking the last tick off leaves selection mode, which is what every list of either kind does and is
|
||||
/// the second way out of it — the cross at the left of the bar being the first. It goes through
|
||||
/// <see cref="ClearHostChoice"/> rather than merely emptying the set, so the menu and any panel it raised
|
||||
/// go with it: a picker asking which vault to move nothing to is not a state worth having.
|
||||
/// </remarks>
|
||||
@@ -5386,6 +5422,89 @@ internal sealed partial class VaultViewModel(
|
||||
ApplyTheChosenHosts();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ticks a whole run of hosts at once, which is what the desktop's pointer builds a selection out of.
|
||||
/// </summary>
|
||||
/// <param name="rows">The hosts to tick.</param>
|
||||
/// <param name="replacing">
|
||||
/// Whether this run is the selection now, or is being added to whatever is already ticked — which is the
|
||||
/// difference between a band dragged across the grid and the same band dragged with Ctrl held.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>A method rather than a command, and it is the one entry into this set that is not a control.</b>
|
||||
/// Every other one is pressed: a long press, a tap, the cross on the bar. This one is handed a run the
|
||||
/// head has just worked out from the pointer — the cards a rubber band crossed, or the cards between two
|
||||
/// clicks — and a command takes a single argument, so binding it would mean inventing a parameter object
|
||||
/// for a caller that is not a binding. See <c>HostsScreen.axaml.cs</c> on the desktop.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Replacing with nothing goes through <see cref="ClearHostChoice"/> rather than merely emptying the set,
|
||||
/// for the reason <see cref="ToggleHostChoice"/> does: the panels and the menu are about the set, and a
|
||||
/// picker asking which vault to move nothing to is not a state worth having. That is the ordinary end of
|
||||
/// a band dragged across empty space, and of a plain click on the grid's background.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal void ChooseHosts(IEnumerable<HostRowViewModel> rows, bool replacing)
|
||||
{
|
||||
if (replacing)
|
||||
{
|
||||
chosenHostIds.Clear();
|
||||
}
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
chosenHostIds.Add(row.EntityId);
|
||||
}
|
||||
|
||||
if (chosenHostIds.Count == 0)
|
||||
{
|
||||
ClearHostChoice();
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyTheChosenHosts();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ticks every host between two cards, which is what a shift-click on the desktop means.
|
||||
/// </summary>
|
||||
/// <param name="anchor">Where the run starts: the last card clicked without shift.</param>
|
||||
/// <param name="to">The card that was shift-clicked.</param>
|
||||
/// <param name="replacing">Whether the run is the selection now, or is added to it.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The order is the grid's own — <see cref="VisibleHosts"/>, which is the collection the cards are drawn
|
||||
/// from — so "between" means what the eye says it means, with whatever filter is in the box and whatever
|
||||
/// group is open already applied. Taking it from <see cref="Hosts"/> instead would tick machines that are
|
||||
/// not on the screen, which is the version of this mistake that ends in a deletion.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Either end missing from that collection ticks nothing rather than guessing. That is a shift-click
|
||||
/// arriving after the run's other end has been filtered away, and the honest answer to it is no run.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal void ChooseHostRun(HostRowViewModel? anchor, HostRowViewModel? to, bool replacing)
|
||||
{
|
||||
if (anchor is null || to is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var from = VisibleHosts.IndexOf(anchor);
|
||||
var until = VisibleHosts.IndexOf(to);
|
||||
|
||||
if (from < 0 || until < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var first = Math.Min(from, until);
|
||||
var last = Math.Max(from, until);
|
||||
|
||||
ChooseHosts(VisibleHosts.Skip(first).Take(last - first + 1).ToList(), replacing);
|
||||
}
|
||||
|
||||
/// <summary>Leaves selection mode, which is the cross at the left of the bar.</summary>
|
||||
/// <remarks>
|
||||
/// It takes the menu and whichever panel was raised from it, because all three are about the set: a
|
||||
@@ -5822,13 +5941,75 @@ internal sealed partial class VaultViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = ChosenHosts;
|
||||
var name = choice.EntityId is null ? "no group" : choice.Label;
|
||||
|
||||
IsRegroupingChosenHosts = false;
|
||||
ChosenHostGroupChoices.Clear();
|
||||
SelectedChosenHostGroup = null;
|
||||
|
||||
await FileTheChosenHostsUnderAsync(
|
||||
choice.EntityId,
|
||||
choice.EntityId is null ? "no group" : choice.Label,
|
||||
cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Files the ticked hosts under the group card a drag of them was let go of.
|
||||
/// </summary>
|
||||
/// <param name="card">The group card the drop landed on.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The desktop's drag, once more than one card is ticked.</b> Dragging one host onto a group is
|
||||
/// <see cref="MoveHostToGroupAsync"/> and always has been; a selection dragged onto one has to file all of
|
||||
/// it, because the alternative — moving whichever card 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>
|
||||
/// <para>
|
||||
/// The two refusals are the ones <see cref="RefusesTheDrop"/> makes for a single card, made once for the
|
||||
/// set: a write under an open editor is a save nobody asked for, and a group belongs to one vault, so a
|
||||
/// selection spanning two cannot be filed under it — the hosts from the other keychain would carry an id
|
||||
/// only its holders can resolve. A read-only host inside the set is skipped rather than refusing the
|
||||
/// whole drop; that is counted and said, in the run below.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// No cancellation token, for the reason <see cref="MoveHostToGroupAsync"/> has none: two drops in quick
|
||||
/// succession are two writes rather than one superseding the other.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private Task FileChosenHostsUnderAsync(HostGroupRowViewModel? card)
|
||||
{
|
||||
if (card is null || !IsChoosingHosts || AHostEditorIsInTheWay())
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (ChosenHosts.Any(row => row.VaultId != card.VaultId))
|
||||
{
|
||||
Status = $"'{card.Label}' is in another keychain to some of these hosts, and a group belongs to "
|
||||
+ "one. Move them to that keychain first, or file the ones already in it.";
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return FileTheChosenHostsUnderAsync(card.EntityId, card.Label, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The write behind both ways a set of ticked hosts is filed: the group picker, and a drag of them onto a
|
||||
/// group card.
|
||||
/// </summary>
|
||||
/// <param name="groupId">The group they end up in, or null for none.</param>
|
||||
/// <param name="name">What to call it on the status line.</param>
|
||||
/// <param name="cancellationToken">The caller's lifetime.</param>
|
||||
/// <remarks>
|
||||
/// Shared rather than written twice, because what "filing a set" means — the hosts a newer version wrote
|
||||
/// are skipped, the ones already there are counted as done, and the selection is dropped once the reload
|
||||
/// has replaced every row — is the part that would drift between two copies of it.
|
||||
/// </remarks>
|
||||
private async Task FileTheChosenHostsUnderAsync(
|
||||
Guid? groupId, string name, CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = ChosenHosts;
|
||||
|
||||
var done = 0;
|
||||
var skipped = 0;
|
||||
|
||||
@@ -5844,7 +6025,7 @@ internal sealed partial class VaultViewModel(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.Host.GroupId == choice.EntityId)
|
||||
if (row.Host.GroupId == groupId)
|
||||
{
|
||||
// Already there. Counted as done rather than skipped: the user asked for these hosts
|
||||
// to be under this heading, and they are.
|
||||
@@ -5856,7 +6037,7 @@ internal sealed partial class VaultViewModel(
|
||||
.UpdateAsync(
|
||||
row.VaultId,
|
||||
row.EntityId,
|
||||
row.Host with { GroupId = choice.EntityId },
|
||||
row.Host with { GroupId = groupId },
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
@@ -5961,6 +6142,10 @@ internal sealed partial class VaultViewModel(
|
||||
IsSendingChosenHostsToAVault = false;
|
||||
IsRegroupingChosenHosts = false;
|
||||
|
||||
// What the count below is about, so that the question goes if the set stops being it.
|
||||
deletionAskedAbout.Clear();
|
||||
deletionAskedAbout.UnionWith(chosenHostIds);
|
||||
|
||||
PendingDeletion = new DeletionRequest(
|
||||
DeletionTarget.ChosenHosts,
|
||||
Guid.Empty,
|
||||
@@ -6133,6 +6318,12 @@ internal sealed partial class VaultViewModel(
|
||||
row.IsChosen = chosenHostIds.Contains(row.EntityId);
|
||||
}
|
||||
|
||||
// A question asked about six hosts is not a question about these seven. See deletionAskedAbout.
|
||||
if (IsConfirmingChosenHostDeletion && !chosenHostIds.SetEquals(deletionAskedAbout))
|
||||
{
|
||||
PendingDeletion = null;
|
||||
}
|
||||
|
||||
// The panels go with the last host, wherever the set emptied from. A sync that removed the only
|
||||
// chosen machine would otherwise leave a vault picker up with nothing behind it.
|
||||
if (chosenHostIds.Count == 0)
|
||||
@@ -6145,6 +6336,7 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(ShowsAddButton));
|
||||
OnPropertyChanged(nameof(ChosenHostCount));
|
||||
OnPropertyChanged(nameof(ChosenHostsLabel));
|
||||
OnPropertyChanged(nameof(ChosenHostsSummary));
|
||||
OnPropertyChanged(nameof(ChosenHosts));
|
||||
OnPropertyChanged(nameof(TheChosenHost));
|
||||
OnPropertyChanged(nameof(HasOneChosenHost));
|
||||
|
||||
Reference in New Issue
Block a user