Let the quick-connect palette answer for itself

Clicking outside the palette did nothing, because nothing was listening: the
wash took no pointer input at all, so the only ways out were a key and the
button that opened it. It now closes on a press whose source is the wash
itself, which is what separates outside from inside — a press on the card
bubbles through the same handler on its way to the window, and closing on
those would make the palette impossible to click into.

The caret never reached the query box either. The window focused it from the
view model's PropertyChanged, and that handler runs before the binding which
reveals the control — measured, with the same wiring, in a replica window. So
it focused a control that was still collapsed, which Avalonia treats as a
no-op and does not replay when the control is revealed, and the keyboard
stayed wherever the click that opened the palette had left it. Becoming
visible is now what triggers it, posted rather than called: a control that has
never been laid out has no visual children, and at the instant IsVisible turns
true the box still reports IsAttachedToVisualTree() == false.

Escape, Enter and the arrows move to the palette as a tunnelled handler.
Answering them only on the window was fragile in the way that matters here:
anything on the route that took a key first would silence them, and with the
focus never landing in the palette the key was being pressed at whatever the
opening click had focused — a focused Button eats Enter. The window keeps
Ctrl+K, which has to work when the palette is not showing, and forwards the
rest as the net for a press that arrives from outside the palette.

Which is also why this moved out of MainWindow rather than being fixed there.
Showing MainWindow initialises WebView2 on a thread it refuses, so nothing on
that window can be tested — the palette shipped with no test of any kind. As a
UserControl it hosts in a bare window and takes real key and pointer input,
and there are now six: press on the wash closes, press on the card does not,
Escape closes, the arrows move the selection without taking the caret out of
the box, Enter takes the highlighted host, and the palette takes the keyboard
when it appears.
This commit is contained in:
2026-07-31 10:43:30 +02:00
parent 94e11f5e38
commit 312d766c30
4 changed files with 489 additions and 64 deletions
@@ -79,9 +79,15 @@ internal sealed partial class MainWindow : Window
/// </summary>
/// <remarks>
/// <para>
/// A tunnelled handler rather than <c>KeyBindings</c>, because three of these four keys have to be
/// intercepted before the control under the pointer sees them: Escape and the arrows belong to the
/// palette while it is open, and the palette's own text box would otherwise eat them.
/// Ctrl+K is here rather than on the palette because it has to work when the palette is not showing, and
/// it is a plain handler rather than a <c>KeyBinding</c> so that toggling stays one code path with the
/// rest of the chord set.
/// </para>
/// <para>
/// The palette's own keys are forwarded rather than answered: <see cref="QuickConnect"/> intercepts them
/// on their way down while the focus is inside it, and this is the net for when it is not — a press that
/// arrives with nothing focused, or from a control on the screen behind, still has to close the palette
/// rather than fall through to whatever is underneath it.
/// </para>
/// <para>
/// None of this reaches the terminal, and it does not need to. Once the WebView's child window holds
@@ -104,61 +110,12 @@ internal sealed partial class MainWindow : Window
}
else if (viewModel.IsSearching)
{
HandlePaletteKey(viewModel, e);
Palette.HandleKey(e);
}
base.OnKeyDown(e);
}
/// <remarks>
/// The selection is moved here rather than by letting the list take focus, because the list taking
/// focus is exactly what would stop the query box receiving the next character typed.
/// </remarks>
private static void HandlePaletteKey(MainWindowViewModel viewModel, KeyEventArgs e)
{
switch (e.Key)
{
case Key.Escape:
viewModel.CloseSearchCommand.Execute(null);
e.Handled = true;
break;
case Key.Enter:
viewModel.ConnectToSearchResultCommand.Execute(null);
e.Handled = true;
break;
case Key.Down:
Move(viewModel, 1);
e.Handled = true;
break;
case Key.Up:
Move(viewModel, -1);
e.Handled = true;
break;
default:
break;
}
}
/// <remarks>Clamped rather than wrapped: a list that jumps from the last row to the first loses people.</remarks>
private static void Move(MainWindowViewModel viewModel, int delta)
{
if (viewModel.SearchResults.Count == 0)
{
return;
}
var current = viewModel.SelectedSearchResult is { } selected
? viewModel.SearchResults.IndexOf(selected)
: -1;
viewModel.SelectedSearchResult =
viewModel.SearchResults[Math.Clamp(current + delta, 0, viewModel.SearchResults.Count - 1)];
}
private void Attach(MainWindowViewModel? viewModel)
{
if (shell is { } previous)
@@ -216,11 +173,18 @@ internal sealed partial class MainWindow : Window
return;
}
// The palette is a text box somebody is expected to start typing into immediately, so opening it
// has to move the caret there — including out of the terminal, which needs the Win32 half as well.
// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is
// expected to start typing into immediately — but the palette does that for itself when it becomes
// visible, which is a moment this handler is measurably ahead of: it runs from the view model's
// PropertyChanged, before the binding that reveals the control, and Focus() on a control that is
// still collapsed is a no-op that is not replayed when it is revealed.
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsSearching), StringComparison.Ordinal))
{
ReleaseKeyboardTo(viewModel.IsSearching ? Palette.QueryBox : KeyboardHome);
if (!viewModel.IsSearching)
{
ReleaseKeyboardTo(KeyboardHome);
}
return;
}