Desktop: let the arrows choose the host the drawer acts on, and Enter connect to it

This commit is contained in:
jaap-jan committed 2026-10-02 13:49:36 +02:00
1 parent 6c09bdecb6
commit 079914bfb4
2 files changed
+146

No files matched your search

@@ -122,6 +122,13 @@ internal sealed partial class HostsScreen : UserControl
// 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
@@ -315,6 +322,16 @@ internal sealed partial class HostsScreen : UserControl
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();
@@ -347,6 +364,80 @@ internal sealed partial class HostsScreen : UserControl
/// 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();
@@ -465,6 +465,61 @@ public sealed class HostGridTests : IAsyncLifetime
});
}
/// <remarks>
/// The arrows' half of the selection. The cards' <c>SelectedItem</c> is one-way, so a card the keyboard lit
/// used to stay out of the vault — and the drawer's CONNECT and DELETE then acted on the card clicked
/// before it, the mistake <see cref="ARightClickSelectsTheHostUnderThePointer"/> is about.
/// </remarks>
[Fact]
public async Task TheArrowsMoveTheVaultsSelection_AndEnterConnectsToIt()
{
await OnTheBoardAsync((screen, window) =>
{
var first = Row(vault, "prod-db");
var other = Row(vault, "stage-web");
Click(CardFor(screen, first), window);
vault.SelectedHost.ShouldBeSameAs(first);
window.KeyPressQwerty(PhysicalKey.ArrowRight, RawInputModifiers.None);
Dispatcher.UIThread.RunJobs();
vault.SelectedHost.ShouldBeSameAs(other, "the card the arrow lit, not the one clicked before it");
vault.ConnectCommand.ExecutionTask.ShouldBeNull();
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
Dispatcher.UIThread.RunJobs();
// The command, not a connection: these hosts were saved with nothing to sign in with, so it stops
// at saying so — about the card Enter was pressed on, which is the selection checked above.
vault.ConnectCommand.ExecutionTask.ShouldNotBeNull("Enter on a card is the double-press");
});
}
/// <remarks>
/// The board is where this screen puts the keyboard, and none of the section lists inside it has it until
/// a card has been clicked — so without this an arrow pressed on arriving went nowhere at all.
/// </remarks>
[Fact]
public async Task AnArrowOnTheBoardItselfGoesIntoTheCards()
{
await OnTheBoardAsync((screen, window) =>
{
vault.SelectedHost = null;
screen.Board.Focus();
Dispatcher.UIThread.RunJobs();
window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None);
Dispatcher.UIThread.RunJobs();
var focused = window.FocusManager!.GetFocusedElement().ShouldBeOfType<ListBoxItem>();
vault.SelectedHost.ShouldNotBeNull().ShouldBeSameAs(focused.DataContext);
});
}
// ---- Helpers ----
private static void RightClick(Visual card, Visual window)