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)