Public Access
Choose more than one host card on the desktop, the way the phone already can
The chosen-hosts set has been in VaultViewModel since the phone's connect card became a contextual action bar: a set of entity ids, a tick on the row, and seven things that can be done to it. Only one head could fill it. The desktop's grid answered a press with one selection — the card the drawer, CONNECT and the context menu are about — so filing eleven imported machines under a heading was eleven drags, and clearing out a vault was eleven rounds of the deletion question. So the pointer gets three ways into the same set. Ctrl-clicks a card to tick it, Shift-clicks to tick the run between the anchor and the card, and drags a band out over the space between and below the cards to tick everything it touches. Esc, CLEAR, a plain click on a card and a click on the empty space each drop it, and Ctrl+A takes every card being drawn — VisibleHosts, so with something in the find box that is the ones on screen and not the ones it is hiding, which is the version of that shortcut whose result can be checked before Delete is pressed. TWO SELECTIONS ON ONE SCREEN, AND KEEPING THEM FROM DISAGREEING IS MOST OF THE CHANGE. Ctrl and Shift are answered on the tunnel and marked handled, so the ListBox never moves its own mark onto the card: a Ctrl-click that also selected would light the card it had just unticked and open the drawer on a machine somebody is removing from a set. A plain press drops the set unless it lands on a ticked card, and that case is deferred to the release, because the press may be the start of a drag of all of it. After any ordinary click exactly one card is in play, which is what makes every command on the screen unambiguous again. The context menu is where the seven live, and it is one markup with two halves gated on IsChoosingHosts. Connect, Browse files and Edit… are drawn only for a single ticked host, as the phone's sheet collapses them and for the same reason; the other four read better for a count. A right click on a card outside the set drops the set first, so a Delete… about the card under the pointer can never be offered while six sit ticked behind the menu — the same rule OnContextRequested has always enforced for the selection, reached from the other direction. No bar of buttons: the phone raises one because it has no other way to hold seven entries, and a strip repeating a menu that already exists would be a second home for the wording that matters most. What the desktop gains instead is a count beside the HOSTS heading, CLEAR, and a sentence saying where the actions are. A drag that starts on a ticked card carries every ticked card. The payload is a list rather than a row now, and a drop of more than one goes through FileChosenHostsUnder, which makes the refusals once — an open editor, and a group belonging to one keychain — and reports a count instead of forty status lines. 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, and the five left behind look filed. The three panels the set's actions raise had never been drawn in a window: the vault picker with its key question, the group picker, and the deletion question. All three sit above the grid rather than over it, which is the arrangement the GROUPS section and the phone's list already use and for the reason written there — the ticked cards are the information the question exists to give, so the grid shortens instead. A DEFECT FOUND BEHIND IT, AND IT WAS ALREADY LIVE ON THE PHONE. The deletion question names a count and the run that answers it reads the set again, and nothing kept the two the same set: the panel is deliberately above a live list, so one more tick between "Delete these 6 hosts?" and pressing DELETE deleted seven, with the seventh named in nothing the user had read. It needed a deliberate act on a phone and a second's work with a band, which is what turned it up. VaultViewModel now remembers which hosts the question was asked about and drops the question when the set stops being them — the question rather than the set, because what somebody has just chosen is what they meant. It also covers the case nobody performs: a colleague's deletion arriving mid-question and shrinking the set under it. VERIFIED. 354 tests in App.Tests and 111 in App.Layout.Tests, build clean, no new warnings. Six gesture tests drive real pointer and key input through the headless window — the modifier click and what it must not do to the selection, the run and its re-measurement from the anchor, the band and the click that drops the set, Ctrl+A under a filter, and the menu's two halves — plus a DragOver carrying two hosts. Four layout tests measure the strip and the three panels at the window's minimum; the vault panel binds a key to its host first, or it would measure the short shape and certify the tall one. Two flow tests cover the multi-drop's write and its refusal, and the deletion question dropping itself. manual-checks gains 7.6a for dragging a set, which no test can see for the reason 7.6 gives, and 7.7a for the gestures — the rectangle actually being painted and the tick and the fill being legible together are the two things the harness cannot look at.
This commit is contained in:
1 parent
1e8a1f2e83
commit
507cd9ff88
8 files changed
+1418
-64
No files matched your search
@@ -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