diff --git a/src/DodoSSH.Client.App/Views/HostsScreen.axaml.cs b/src/DodoSSH.Client.App/Views/HostsScreen.axaml.cs
index aef33aa..794a00a 100644
--- a/src/DodoSSH.Client.App/Views/HostsScreen.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/HostsScreen.axaml.cs
@@ -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 .
///
///
+ /// Enter on a card is the double-press, for somebody who got there with the arrows.
+ ///
+ /// Only on a card, for the reason 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.
+ ///
+ 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;
+ }
+
+ /// Writes a selection the arrows made in one section's list back to the vault.
+ ///
+ ///
+ /// The cards' SelectedItem 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.
+ ///
+ ///
+ /// ◆ 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
+ /// has already written the same card, and changes nothing.
+ ///
+ ///
+ 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;
+ }
+
+ /// Hands the keyboard from the board to a card: the selected one if it is drawn, else the first.
+ private bool EnterTheCards(VaultViewModel vault)
+ {
+ var cards = Board.GetVisualDescendants().OfType().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();
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs
index 0215839..c8a8424 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs
@@ -465,6 +465,61 @@ public sealed class HostGridTests : IAsyncLifetime
});
}
+ ///
+ /// The arrows' half of the selection. The cards' SelectedItem 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 is about.
+ ///
+ [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");
+ });
+ }
+
+ ///
+ /// 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.
+ ///
+ [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();
+
+ vault.SelectedHost.ShouldNotBeNull().ShouldBeSameAs(focused.DataContext);
+ });
+ }
+
// ---- Helpers ----
private static void RightClick(Visual card, Visual window)