Public Access
642 lines
30 KiB
C#
642 lines
30 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.Primitives;
|
|
using Avalonia.Input;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.VisualTree;
|
|
using DodoSSH.Client.Shell.ViewModels;
|
|
|
|
namespace DodoSSH.Client.App.Views;
|
|
|
|
/// <summary>
|
|
/// The flat board of host cards, and everything on it that is a gesture rather than a binding.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ◆ <b>v5: THE GRID OF GROUP CARDS AND THE BREADCRUMB TRAIL ARE GONE, AND SO IS THE DRAG.</b> The desktop
|
|
/// used to hold one level of the group tree at a time — a wrap of group cards above the hosts, opened by a
|
|
/// double-click, with a trail above it saying where you were — the way a directory pane holds one directory.
|
|
/// That model is retired outright rather than folded into the new board: every group is now a heading, in
|
|
/// label order, all of them at once, exactly the flattening the phone's <c>SidebarRows</c> has always drawn.
|
|
/// A host is filed under a group through the group's own picker in its editor, or through the "Change
|
|
/// group…" entry the chosen-hosts menu already carried — dragging a card onto another card had exactly one
|
|
/// destination, a group card, and that card is what left. See <c>VaultViewModel.HostSections</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// ◆ <b>ONE SECTION, ONE <c>ListBox</c>.</b> The two shapes worth weighing were a single mixed list of
|
|
/// header rows and host rows in one <c>WrapPanel</c>, or an <c>ItemsControl</c> of sections each holding its
|
|
/// own card list. The first is what the phone's single flat list already is, and reusing it here would have
|
|
/// meant a header row pretending to be as wide as the row it sits on so the wrap panel breaks a line before
|
|
/// and after it — a real technique, and a fragile one to get right at every width this window can be resized
|
|
/// to. The second costs a real thing: there is no longer one <c>ListBox</c> owning one <c>SelectedItem</c>,
|
|
/// so multi-select and the marquee have to be taught to reach across however many section lists are on
|
|
/// screen. That is what most of this file now does. It was chosen anyway, because a <c>WrapPanel</c> that
|
|
/// only ever holds cards of one kind is the ordinary, well-trodden case, and "reach across N lists instead
|
|
/// of one" is a bounded, mechanical problem — enumerate every list, not the fixed one named in the markup —
|
|
/// where "make a wrap panel break a line for one specific child" is a harder one to be sure of without a
|
|
/// custom panel this application does not otherwise need.
|
|
/// </para>
|
|
/// <para>
|
|
/// ◆ <b>SELECTION IS ONE-WAY INTO EVERY SECTION AND WRITTEN BY HAND ON THE WAY OUT.</b> Each section's
|
|
/// <c>ListBox</c> binds <c>SelectedItem</c> from <c>VaultViewModel.SelectedSidebarRow</c> so the right card
|
|
/// lights up wherever it lives, but not back — a two-way binding shared by several independently-rebuilt
|
|
/// lists would have each list's own <c>Reset</c> (one per filter keystroke, one per background sync) racing
|
|
/// to null the one shared property, which is the same hazard <c>VaultViewModel.RebuildVisibleHosts</c>
|
|
/// already documents for a single list and multiplies it by the section count. So a card being pressed is
|
|
/// what actually moves the selection, here in code, exactly as a Ctrl or Shift modifier already had to be —
|
|
/// see <see cref="OnPointerPressed"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// ◆ <b>THERE ARE STILL TWO SELECTIONS ON THIS SCREEN, AND THEY ARE STILL NOT THE SAME THING.</b>
|
|
/// <c>SelectedHost</c> is the card the drawer, CONNECT and the ordinary menu are about; the chosen set is
|
|
/// what Ctrl, Shift and the band build on top of it, as <c>IsChosen</c> on the row. Every handler below that
|
|
/// reads a modifier is about keeping the two from being confused for one another, on the same terms this
|
|
/// screen has always used — see <c>VaultViewModel.ChooseHosts</c> and Android's <c>HostsScreen</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// Its data context is the <c>VaultViewModel</c>, as <see cref="KeychainScreen"/>'s is. The window hands it
|
|
/// over; see <see cref="MainWindow"/>.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed partial class HostsScreen : UserControl
|
|
{
|
|
/// <summary>How far the pointer has to travel before a press on the empty space becomes a band.</summary>
|
|
/// <remarks>
|
|
/// A threshold, because a press on the space around the cards is nearly always a click that drops the
|
|
/// chosen set — see <see cref="OnBandPressed"/>. Starting a band on the press itself would turn every one
|
|
/// of those into a rectangle nobody asked for.
|
|
/// </remarks>
|
|
private const double DragThreshold = 5;
|
|
|
|
/// <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 board falls back
|
|
/// to the selected card for.
|
|
/// </remarks>
|
|
private HostRowViewModel? anchor;
|
|
|
|
/// <summary>Whether a band is being dragged out over the board 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();
|
|
|
|
// Wired here rather than in the markup because it is a gesture rather than a binding, which is how
|
|
// the transfers screen opens a directory too. Attached to the board rather than to any one section's
|
|
// list, since double-tapping a card in any of them means the same thing and DoubleTapped bubbles.
|
|
Board.DoubleTapped += OnHostActivated;
|
|
|
|
// Tunnelled, so the card under the pointer is read before whichever section's ListBox has answered
|
|
// the press itself. Bubbling would work for a plain click but not for the menu or for Ctrl/Shift: by
|
|
// the time either reaches this control the list has already decided what it thinks is selected.
|
|
Board.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
|
|
Board.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
|
|
|
|
WireTheBand();
|
|
|
|
HostFilter.TextChanged += OnFilterTyped;
|
|
}
|
|
|
|
/// <summary>How long the find box waits for the typing to pause before the board is rebuilt.</summary>
|
|
private static readonly TimeSpan FilterPause = TimeSpan.FromMilliseconds(150);
|
|
|
|
/// <summary>The wait itself, replaceable so a test can say when a pause is over rather than sleep through one.</summary>
|
|
internal Func<TimeSpan, Task> Pause { get; set; } = static span => Task.Delay(span);
|
|
|
|
/// <summary>How many times the find box has changed, so a wait can tell it is no longer the latest.</summary>
|
|
private int typed;
|
|
|
|
/// <summary>Hands the find box's text to the vault once the typing pauses.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Every change to the filter rebuilds the board's sections and every card in them, and a name typed at
|
|
/// speed used to do that once a letter. Now it is done once, with the whole of what was typed.
|
|
/// </para>
|
|
/// <para>
|
|
/// A wait rather than a binding delay, and on the dispatcher's own queue: the box is bound one way, so a
|
|
/// filter the vault sets itself — cleared, or restored — still lands in the box at once, and the
|
|
/// change it makes to the box comes back here as the same text, which changes nothing.
|
|
/// </para>
|
|
/// </remarks>
|
|
private void OnFilterTyped(object? sender, TextChangedEventArgs e) =>
|
|
_ = PassTheFilterOnAsync(HostFilter.Text ?? string.Empty, ++typed);
|
|
|
|
private async Task PassTheFilterOnAsync(string text, int keystroke)
|
|
{
|
|
await Pause(FilterPause).ConfigureAwait(true);
|
|
|
|
// A later letter has its own wait, and it is the one that speaks.
|
|
if (keystroke != typed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (Vault is { } vault && !string.Equals(vault.HostFilter, text, StringComparison.Ordinal))
|
|
{
|
|
vault.HostFilter = text;
|
|
}
|
|
}
|
|
|
|
/// <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 board 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 board 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.
|
|
Board.KeyDown += OnGridKey;
|
|
|
|
// The arrows' half of the selection, which the pointer's half writes by hand in OnPointerPressed. See
|
|
// OnCardSelectionChanged for why this is not simply a two-way binding.
|
|
Board.AddHandler(SelectingItemsControl.SelectionChangedEvent, OnCardSelectionChanged);
|
|
|
|
// Tunnelled, because a section's list answers Enter itself and nothing bubbles past it.
|
|
Board.AddHandler(KeyDownEvent, OnCardEnter, RoutingStrategies.Tunnel);
|
|
|
|
// ◆ The band is the scroller's rather than any one section list's, because the space it is dragged
|
|
// out over is mostly not a list's: a WrapPanel of cards is exactly as tall as its cards, and the gaps
|
|
// between sections and below the last one belong to the stack around them. Tunnelled for the reason
|
|
// the board'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.
|
|
/// </remarks>
|
|
private VaultViewModel? Vault => DataContext as VaultViewModel;
|
|
|
|
/// <summary>
|
|
/// Where the keyboard should land when the terminal hands it back.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Exposed as a property rather than left for the window to find by name, because the name is inside
|
|
/// this control's template and the window cannot see it.
|
|
/// </para>
|
|
/// <para>
|
|
/// It has to be a control the keyboard can actually go to. <c>Focus()</c> on a collapsed control is
|
|
/// measurably a no-op and is not replayed when the control is revealed, so handing the keyboard to
|
|
/// something that is not there would swallow it. <c>Board</c> is focusable and holds every section, so it
|
|
/// answers as long as one card is on it; an empty board is what a filter matching nothing produces, and
|
|
/// the find box is the answer then — it is where they were typing.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal IInputElement KeyboardTarget =>
|
|
Vault is { HasHostBoardEntries: true } ? Board : HostFilter;
|
|
|
|
/// <remarks>
|
|
/// Fire-and-forget, as the transfers screen's is: the command reports its own failures onto the status
|
|
/// line — an unknown host key, a refused password — and awaiting it here would mean an event handler
|
|
/// returning a task nothing observes.
|
|
/// </remarks>
|
|
private void OnHostActivated(object? sender, TappedEventArgs e)
|
|
{
|
|
// Only over a card. A double-tap on the space around them, or on a section heading, must not connect
|
|
// to whichever host was selected before — which is what an unguarded handler would do, on a machine
|
|
// the user is not even pointing at.
|
|
if (Vault is { } vault && RowUnder(e.Source) is HostRowViewModel)
|
|
{
|
|
_ = vault.ConnectCommand.ExecuteAsync(null);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Points the menu at whatever was right-clicked.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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, or on a section heading. Neither is a host, and a
|
|
/// menu offering Connect, Edit and Delete over either would be entries that either do nothing or act on
|
|
/// something else entirely.
|
|
/// </para>
|
|
/// </remarks>
|
|
private void OnContextRequested(object? sender, ContextRequestedEventArgs e)
|
|
{
|
|
if (Vault is not { } vault || RowUnder(e.Source) is not HostRowViewModel row)
|
|
{
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
|
|
if (vault.IsChoosingHosts && !row.IsChosen)
|
|
{
|
|
vault.ClearHostChoiceCommand.Execute(null);
|
|
}
|
|
|
|
if (!vault.IsChoosingHosts)
|
|
{
|
|
vault.SelectedHost = row;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ticks cards, or moves the selection.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ◆ <b>Ctrl and Shift are answered here and go no further.</b> Both mark the event handled, which is
|
|
/// what stops whichever section's <c>ListBox</c> the press landed in 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 moves the selection and drops the chosen set</b>, which is every file manager's rule
|
|
/// and the reason the two selections on this screen can never disagree: after an ordinary click there is
|
|
/// exactly one card in play. Not marked handled — the section list's own native selection is left to
|
|
/// follow along, which is harmless now that it is only <c>OneWay</c> bound: whatever it shows locally is
|
|
/// overwritten the moment <see cref="VaultViewModel.SelectedHost"/>'s change reaches every section again.
|
|
/// </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 board 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)
|
|
{
|
|
if (Vault is not { } vault
|
|
|| !e.GetCurrentPoint(Board).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);
|
|
|
|
Board.Focus();
|
|
e.Handled = true;
|
|
|
|
return;
|
|
}
|
|
|
|
if (control)
|
|
{
|
|
// Extend rather than toggle: the set this starts has the lit card in it, which is the half a
|
|
// plain toggle left out. See VaultViewModel.ExtendHostChoice.
|
|
vault.ExtendHostChoiceCommand.Execute(row);
|
|
anchor = row;
|
|
|
|
Board.Focus();
|
|
e.Handled = true;
|
|
|
|
return;
|
|
}
|
|
|
|
anchor = row;
|
|
|
|
if (vault.IsChoosingHosts)
|
|
{
|
|
vault.ClearHostChoiceCommand.Execute(null);
|
|
}
|
|
|
|
vault.SelectedHost = row;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Esc drops the set, and Ctrl+A is every card on the board.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Every card <em>on the board</em>: <see cref="VaultViewModel.HostBoardOrder"/>, which is every section
|
|
/// with whatever the find box and the two toolbar flyouts have already narrowed it to, and every card a
|
|
/// fold has not hidden. Ctrl+A over a filtered board that quietly ticked machines it is not showing 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;
|
|
}
|
|
|
|
// An arrow with the board itself focused — which is where this screen puts the keyboard, see
|
|
// KeyboardTarget — goes into the cards: onto the selected one, or the first. Otherwise no arrow ever
|
|
// reaches a section's list, since none of them has the keyboard until a card has been clicked.
|
|
if (e.Key is Key.Up or Key.Down or Key.Left or Key.Right && ReferenceEquals(e.Source, Board))
|
|
{
|
|
e.Handled = EnterTheCards(vault);
|
|
|
|
return;
|
|
}
|
|
|
|
if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasHostBoardEntries)
|
|
{
|
|
var order = vault.HostBoardOrder.ToList();
|
|
|
|
vault.ChooseHosts(order, replacing: true);
|
|
anchor = order[0];
|
|
e.Handled = true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Starts a band on the empty space between and below the cards.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ◆ <b>Only where there is nothing to press.</b> The scroller holds every section heading, every card
|
|
/// list 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. <see cref="TakesThePress"/> is
|
|
/// the guard, and it is ancestor-based rather than naming one control, which is what lets it work
|
|
/// regardless of how many section lists happen to be realized.
|
|
/// </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 board 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>
|
|
/// <summary>Enter on a card is the double-press, for somebody who got there with the arrows.</summary>
|
|
/// <remarks>
|
|
/// Only on a card, for the reason <see cref="OnHostActivated"/> gives: Enter with the board itself focused
|
|
/// would connect to whatever was selected before, which is not something anyone is looking at. And not
|
|
/// while cards are ticked, where the selection is not what the set is about.
|
|
/// </remarks>
|
|
private void OnCardEnter(object? sender, KeyEventArgs e)
|
|
{
|
|
if (e.Key is not Key.Enter
|
|
|| e.KeyModifiers != KeyModifiers.None
|
|
|| Vault is not { IsChoosingHosts: false } vault
|
|
|| RowUnder(e.Source) is not HostRowViewModel row)
|
|
{
|
|
return;
|
|
}
|
|
|
|
vault.SelectedHost = row;
|
|
|
|
_ = vault.ConnectCommand.ExecuteAsync(null);
|
|
e.Handled = true;
|
|
}
|
|
|
|
/// <summary>Writes a selection the arrows made in one section's list back to the vault.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The cards' <c>SelectedItem</c> is one-way, for the reason the markup gives: several lists rebuilt
|
|
/// independently would each null the vault's selection on every filter keystroke and every sync pass. So
|
|
/// the pointer writes it by hand, and until this nothing wrote it for the arrows — a card lit by the
|
|
/// keyboard was not the one the drawer's CONNECT and DELETE then acted on, which is the same mistake the
|
|
/// right-click handler exists to prevent.
|
|
/// </para>
|
|
/// <para>
|
|
/// ◆ Only from the list that has the keyboard, and only for a card. A rebuild's reset arrives as a change
|
|
/// to nothing, and the binding's own refills of the lists nobody is in arrive without the keyboard — both
|
|
/// are exactly the writes the one-way binding keeps out, and they stay out. A click arrives here too, after
|
|
/// <see cref="OnPointerPressed"/> has already written the same card, and changes nothing.
|
|
/// </para>
|
|
/// </remarks>
|
|
private void OnCardSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
|
{
|
|
if (Vault is not { } vault
|
|
|| e.Source is not ListBox { IsKeyboardFocusWithin: true, SelectedItem: HostRowViewModel row }
|
|
|| ReferenceEquals(row, vault.SelectedHost))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (vault.IsChoosingHosts)
|
|
{
|
|
vault.ClearHostChoiceCommand.Execute(null);
|
|
}
|
|
|
|
anchor = row;
|
|
vault.SelectedHost = row;
|
|
}
|
|
|
|
/// <summary>Hands the keyboard from the board to a card: the selected one if it is drawn, else the first.</summary>
|
|
private bool EnterTheCards(VaultViewModel vault)
|
|
{
|
|
var cards = Board.GetVisualDescendants().OfType<ListBoxItem>().Where(card => card.IsEffectivelyVisible).ToList();
|
|
|
|
var card = cards.Find(card => ReferenceEquals(card.DataContext, vault.SelectedHost)) ?? cards.FirstOrDefault();
|
|
|
|
if (card?.DataContext is not HostRowViewModel row)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
vault.SelectedHost = row;
|
|
anchor = row;
|
|
|
|
return card.Focus(NavigationMethod.Directional);
|
|
}
|
|
|
|
private void OnBandPressed(object? sender, PointerPressedEventArgs e)
|
|
{
|
|
EndBand();
|
|
|
|
if (Vault is null
|
|
|| !e.GetCurrentPoint(Scroll).Properties.IsLeftButtonPressed
|
|
|| TakesThePress(e.Source))
|
|
{
|
|
return;
|
|
}
|
|
|
|
banding = true;
|
|
bandFrom = e.GetPosition(Scroll);
|
|
bandAdds = e.KeyModifiers.HasFlag(KeyModifiers.Control);
|
|
|
|
e.Pointer.Capture(Scroll);
|
|
Board.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: a rectangle that had to
|
|
/// swallow a card whole would tick nothing for a band drawn down the middle of a column. Gathered from
|
|
/// every section's own list — see <see cref="AllCardContainers"/> — rather than one named control, since
|
|
/// a band drawn across two sections has to touch cards in both.
|
|
/// </remarks>
|
|
private List<HostRowViewModel> CardsIn(Rect box)
|
|
{
|
|
var hit = new List<HostRowViewModel>();
|
|
|
|
foreach (var container in AllCardContainers())
|
|
{
|
|
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>Every realized card, across every section's own list.</summary>
|
|
/// <remarks>
|
|
/// A section is a <c>ListBox</c> of its own — see the type's remarks — so there is no single control
|
|
/// whose <c>GetRealizedContainers()</c> would answer for the whole board. Found by class rather than by
|
|
/// name, because how many sections exist is a fact about the keychain rather than the markup: a flat
|
|
/// keychain draws one, a filed one draws several, and neither shape is known until the vault says so.
|
|
/// </remarks>
|
|
private List<ListBoxItem> AllCardContainers() =>
|
|
[.. this.GetVisualDescendants()
|
|
.OfType<ListBox>()
|
|
.Where(list => list.Classes.Contains("sectioncards"))
|
|
.SelectMany(list => list.GetRealizedContainers())
|
|
.OfType<ListBoxItem>()];
|
|
|
|
/// <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 or in a section's own
|
|
/// heading. Missing the scrollbar is the one that would be felt every day — dragging the thumb would
|
|
/// paint a band down the board 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>The view model of the grid item an event happened on, if it happened on a host card.</summary>
|
|
private static HostRowViewModel? RowUnder(object? source) =>
|
|
Container(source)?.DataContext as HostRowViewModel;
|
|
|
|
/// <remarks>
|
|
/// Walks up from whatever was actually hit — a text block, a border, the card's own grid — because that
|
|
/// is what an event's source is. Anything not inside a card, which includes a section's own heading and
|
|
/// the space around them, yields null.
|
|
/// </remarks>
|
|
private static ListBoxItem? Container(object? source) => source is Visual visual
|
|
? visual.FindAncestorOfType<ListBoxItem>(includeSelf: true)
|
|
: null;
|
|
}
|