using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Threading; using DodoSSH.Client.Shell.ViewModels; namespace DodoSSH.Client.App.Views; /// The Ctrl+K host search, overlaid on the window. /// /// /// Everything a palette does once it is open belongs here rather than in : the four /// keys it answers to, the press outside it that dismisses it, and taking the keyboard the moment it appears. /// Opening it is the window's business, because Ctrl+K has to work when this control is not showing. /// /// /// The split used to fall the other way, and the cost was that none of it could be tested. Showing /// initialises WebView2, which refuses the headless dispatcher's thread — see /// LayoutHarnessTests.WhyTheWindowItselfIsNeverShown — so behaviour that lived on the window could only /// be checked by hand. A hosts in a bare window and takes real key and pointer /// input. /// /// internal sealed partial class QuickConnect : UserControl { public QuickConnect() { InitializeComponent(); // Tunnelled, and deliberately: the query box below is on the route these keys take, and a text box // that grows a use for Enter or the arrows — a multi-line box, a completion list — would take them // before a bubbling handler here ever ran. The palette owns them while it is open, so it says so at // the point on the route where nothing else has had a chance yet. AddHandler(KeyDownEvent, OnPaletteKey, RoutingStrategies.Tunnel); // A click on a result connects to it, which is the pointer's version of what Enter already does. // Tapped rather than PointerPressed: a press has not chosen anything yet — it is also the start of a // drag across the list — and the ListBox has moved its own selection by the time a tap completes, // which is what makes the handler below a matter of reading the selection rather than hit-testing. Results.Tapped += OnResultTapped; AdHocRow.Tapped += OnAdHocRowTapped; // The two steps swap in place, and whichever one has just appeared takes the caret: typing an address // and pressing Enter should leave somebody typing a password, not looking for the box to click into. // Watched on the steps themselves rather than on the view model, for the reason TakeKeyboard gives — // the view model's change arrives ahead of the binding that reveals the step. QueryStep.PropertyChanged += OnStepChanged; PasswordStep.PropertyChanged += OnStepChanged; } /// The box, so the caret can be put in it the moment the palette opens. internal TextBox QueryBox => Query; /// The typed address's password box, which takes the caret when the palette asks for it. internal TextBox PasswordBox => Password; /// The row offering to dial what was typed, when it reads as an address. internal Control AdHocTargetRow => AdHocRow; private MainWindowViewModel? Shell => DataContext as MainWindowViewModel; /// /// Answers one of the palette's keys, wherever in the window it was pressed. /// /// /// Internal because calls it too, for the case this control's own tunnelled /// handler cannot see: a key routes through here only while the focus is inside the palette, and the /// window is what catches Escape when it is not. /// internal void HandleKey(KeyEventArgs e) { ArgumentNullException.ThrowIfNull(e); if (Shell is not { IsSearching: true } shell) { return; } // The password step answers only Enter and Escape, and Escape there goes back to the address: a // mistyped address is the likeliest reason to want out of it, and closing would throw that away too. if (shell.IsAskingForAdHocPassword) { switch (e.Key) { case Key.Escape: shell.BackToSearchQueryCommand.Execute(null); e.Handled = true; break; case Key.Enter: _ = shell.ConnectToAdHocTargetCommand.ExecuteAsync(null); e.Handled = true; break; default: break; } return; } switch (e.Key) { case Key.Escape: shell.CloseSearchCommand.Execute(null); e.Handled = true; break; case Key.Enter: shell.ConnectToSearchResultCommand.Execute(null); e.Handled = true; break; case Key.Down: shell.MoveSearchSelection(1); e.Handled = true; break; case Key.Up: shell.MoveSearchSelection(-1); e.Handled = true; break; default: break; } } /// /// Takes the keyboard, so the palette can be typed into the instant it appears. /// /// /// /// Both halves, for the reason gives: the terminal's WebView is a native /// child window that keeps Win32 focus even after it is collapsed, so focusing an Avalonia control without /// the Win32 call produces a box with a caret in it that silently receives nothing. /// /// /// Done here rather than by the window, and that is the fix rather than a tidying. Focus() on a /// collapsed control is measurably a no-op that is not replayed when the control is revealed, and the /// window's own attempt ran from the view model's PropertyChanged — ahead of the binding that makes /// this control visible, so it focused a control that was still collapsed and the keyboard stayed wherever /// it was. /// /// /// Becoming visible is still too early on its own, which is why this is posted rather than called. A /// control that has never been laid out has no visual children — measured: at the instant /// IsVisible turns true the query box reports IsAttachedToVisualTree() == false, and focus is /// refused to anything not in the tree. Layout runs at a higher priority than this callback, so by the /// time it is picked up the box exists. /// /// private void TakeKeyboard() { // The palette can have been dismissed between the post and the callback — a press on the wash, or a // second Ctrl+K — and stealing the keyboard back into a control nobody can see would be worse than // arriving late. if (!IsVisible) { return; } if (TopLevel.GetTopLevel(this) is Window window) { NativeKeyboardFocus.ReturnTo(window); } if (PasswordStep.IsVisible) { Password.Focus(); } else { Query.Focus(); } } /// Hands the caret to whichever step has just been revealed. /// Posted, for the same reason the palette's own opening is: see . private void OnStepChanged(object? sender, AvaloniaPropertyChangedEventArgs e) { if (e.Property == IsVisibleProperty && e.GetNewValue() && IsVisible) { Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded); } } /// /// Takes the typed address, for a click on its row: the pointer's version of Enter on it. /// private void OnAdHocRowTapped(object? sender, TappedEventArgs e) { if (Shell is not { IsSearching: true, HasAdHocTarget: true } shell) { return; } e.Handled = true; shell.ChooseAdHocTargetCommand.Execute(null); } /// protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { // Base first, so the visibility this control has just been given has already reached its descendants: // focus is refused to anything not effectively visible, and the box below is a descendant. base.OnPropertyChanged(change); if (change.Property == IsVisibleProperty && change.GetNewValue()) { Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded); } } private void OnPaletteKey(object? sender, KeyEventArgs e) => HandleKey(e); /// /// Connects to the result that was clicked. /// /// /// Goes through the shell's own command rather than reading the row out of the event, so the pointer and /// the keyboard take exactly the same path: the palette closes, the hosts screen is put back in case the /// connection has a question to ask, and the vault's connect command makes every refusal it already /// makes. Fire-and-forget, as the sidebar's double-click is — the command reports its own failures onto /// the status line and into the tab it opens. /// private void OnResultTapped(object? sender, TappedEventArgs e) { if (Shell is not { IsSearching: true, SelectedSearchResult: not null } shell) { return; } e.Handled = true; _ = shell.ConnectToSearchResultCommand.ExecuteAsync(null); } /// /// Only a press on the wash itself. Presses on the card bubble through here as well, and closing on those /// would make the palette impossible to click into. /// private void OnBackdropPressed(object? sender, PointerPressedEventArgs e) { if (!ReferenceEquals(e.Source, Backdrop) || Shell is not { IsSearching: true } shell) { return; } shell.CloseSearchCommand.Execute(null); e.Handled = true; } }