Public Access
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,13 @@
|
||||
an overlay across the middle of this window would be painted underneath it and take no clicks.
|
||||
-->
|
||||
|
||||
<Border Background="#CC0A0C0B">
|
||||
<!--
|
||||
The wash is named and takes a press, because clicking away from a palette is how every palette closes.
|
||||
The handler answers only for presses whose source is the wash itself, which is what separates "outside"
|
||||
from "inside": a press anywhere on the card below reports that control as its source and bubbles through
|
||||
here on its way to the window.
|
||||
-->
|
||||
<Border x:Name="Backdrop" Background="#CC0A0C0B" PointerPressed="OnBackdropPressed">
|
||||
<Border Width="520" VerticalAlignment="Top" Margin="0,90,0,0"
|
||||
Background="{StaticResource Chrome}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="6">
|
||||
@@ -39,8 +45,8 @@
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
Arrow keys move the selection and Enter takes it; the window's key handler owns both, because a
|
||||
ListBox that took focus would take the arrow keys away from the box being typed into.
|
||||
Arrow keys move the selection and Enter takes it; the code-behind owns both, because a ListBox that
|
||||
took focus would take the arrow keys away from the box being typed into.
|
||||
-->
|
||||
<ListBox x:Name="Results" MaxHeight="280" Focusable="False"
|
||||
ItemsSource="{Binding SearchResults}"
|
||||
|
||||
@@ -1,17 +1,174 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>The Ctrl+K host search, overlaid on the window.</summary>
|
||||
/// <remarks>
|
||||
/// The keys it responds to are handled by <see cref="MainWindow"/> rather than here, because two of them —
|
||||
/// Ctrl+K to open and Escape to close — have to work when this control does not exist yet or has just
|
||||
/// stopped existing. Handling the arrows in the same place keeps the whole chord set in one method.
|
||||
/// <para>
|
||||
/// Everything a palette does once it is open belongs here rather than in <see cref="MainWindow"/>: 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The split used to fall the other way, and the cost was that none of it could be tested. Showing
|
||||
/// <see cref="MainWindow"/> initialises WebView2, which refuses the headless dispatcher's thread — see
|
||||
/// <c>LayoutHarnessTests.WhyTheWindowItselfIsNeverShown</c> — so behaviour that lived on the window could only
|
||||
/// be checked by hand. A <see cref="UserControl"/> hosts in a bare window and takes real key and pointer
|
||||
/// input.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class QuickConnect : UserControl
|
||||
{
|
||||
public QuickConnect() => InitializeComponent();
|
||||
public QuickConnect()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
/// <summary>The box, so the window can put the caret in it the moment the palette opens.</summary>
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <summary>The box, so the caret can be put in it the moment the palette opens.</summary>
|
||||
internal TextBox QueryBox => Query;
|
||||
|
||||
private MainWindowViewModel? Shell => DataContext as MainWindowViewModel;
|
||||
|
||||
/// <summary>
|
||||
/// Answers one of the palette's keys, wherever in the window it was pressed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Internal because <see cref="MainWindow"/> 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.
|
||||
/// </remarks>
|
||||
internal void HandleKey(KeyEventArgs e)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(e);
|
||||
|
||||
if (Shell is not { IsSearching: true } shell)
|
||||
{
|
||||
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:
|
||||
Move(shell, 1);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Up:
|
||||
Move(shell, -1);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the keyboard, so the palette can be typed into the instant it appears.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Both halves, for the reason <see cref="NativeKeyboardFocus"/> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Done here rather than by the window, and that is the fix rather than a tidying. <c>Focus()</c> 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 <c>PropertyChanged</c> — 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// <c>IsVisible</c> turns true the query box reports <c>IsAttachedToVisualTree() == false</c>, 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
Query.Focus();
|
||||
}
|
||||
|
||||
/// <remarks>Clamped rather than wrapped: a list that jumps from the last row to the first loses people.</remarks>
|
||||
private static void Move(MainWindowViewModel shell, int delta)
|
||||
{
|
||||
if (shell.SearchResults.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var current = shell.SelectedSearchResult is { } selected
|
||||
? shell.SearchResults.IndexOf(selected)
|
||||
: -1;
|
||||
|
||||
shell.SelectedSearchResult =
|
||||
shell.SearchResults[Math.Clamp(current + delta, 0, shell.SearchResults.Count - 1)];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<bool>())
|
||||
{
|
||||
Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPaletteKey(object? sender, KeyEventArgs e) => HandleKey(e);
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Session.Tests;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
using DodoSSH.Crypto;
|
||||
using NSubstitute;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// How the quick-connect palette answers a keyboard and a pointer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the suite the palette shipped without, and the reason it shipped without one is that all of this
|
||||
/// used to live on <c>MainWindow</c> — which cannot be shown here at all, because attaching the terminal's
|
||||
/// WebView initialises WebView2 on a thread it refuses. See
|
||||
/// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/>. A <c>UserControl</c> hosts in a bare
|
||||
/// window, takes real key and pointer input, and can therefore be held to what it promises.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Three things were wrong and each has a test here: nothing answered a press outside the palette, so the one
|
||||
/// gesture everybody tries first did nothing; the caret never reached the query box, because the window
|
||||
/// focused it from the view model's <c>PropertyChanged</c> — ahead of the binding that reveals the control,
|
||||
/// and focus on a collapsed control is a no-op; and the keys were answered only by a handler on the window,
|
||||
/// which anything on the route could have taken first.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A real <see cref="MainWindowViewModel"/> over a real unlocked vault, for the same reason the layout suite
|
||||
/// uses one: compiled bindings resolve against the declared type, and the palette's list is populated by the
|
||||
/// vault's own hosts. Nothing here reaches a network — the connect the Enter test performs fails inside the
|
||||
/// vault's own error handling, which is fine, because what Enter promises is to take the highlighted result
|
||||
/// and close.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class QuickConnectTests : IAsyncLifetime
|
||||
{
|
||||
private const string Passphrase = "a sufficiently long passphrase";
|
||||
private const string ServerUrl = "https://dodossh.example";
|
||||
|
||||
/// <remarks>Far below the shipped profile: nothing here attacks a wrap.</remarks>
|
||||
private static readonly Argon2Profile CheapProfile =
|
||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||
|
||||
private readonly FakeAccountServer server = new();
|
||||
private readonly StubKeyBinding keyBinding = new();
|
||||
private readonly VaultKnownHostStore knownHosts = new();
|
||||
|
||||
private ClientCacheFactory caches = null!;
|
||||
private TerminalWorkspace workspace = null!;
|
||||
private VaultSession session = null!;
|
||||
private VaultViewModel vault = null!;
|
||||
private MainWindowViewModel shell = null!;
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
caches = ClientCacheFactory.ForMemory($"palette-{Guid.CreateVersion7():N}");
|
||||
await caches.MigrateAsync(Token);
|
||||
|
||||
await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
|
||||
.EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
|
||||
|
||||
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
|
||||
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
|
||||
session = outcome.Session!;
|
||||
|
||||
workspace = new TerminalWorkspace(
|
||||
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||
Substitute.For<ISshConnectionFactory>(),
|
||||
TimeProvider.System);
|
||||
|
||||
await knownHosts.OpenAsync(session, Token);
|
||||
|
||||
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
|
||||
|
||||
await SeedAsync();
|
||||
|
||||
shell = new MainWindowViewModel(
|
||||
ClientPaths.Default,
|
||||
caches,
|
||||
workspace,
|
||||
knownHosts,
|
||||
Substitute.For<IDeviceKeyStore>(),
|
||||
(_, _) => throw new NotSupportedException("nothing here signs in"),
|
||||
TimeProvider.System,
|
||||
CheapProfile)
|
||||
{
|
||||
// The state the palette is only ever open in. Assigned rather than reached through the unlock
|
||||
// path, which would be a second enrollment and a second Argon2 pass for no extra coverage.
|
||||
State = ShellState.Unlocked,
|
||||
Vault = vault,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await shell.DisposeAsync();
|
||||
knownHosts.Close();
|
||||
await workspace.DisposeAsync();
|
||||
await session.DisposeAsync();
|
||||
caches.Dispose();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The gesture everybody tries first, and the one that did nothing at all: the wash took no pointer input,
|
||||
/// so the only ways out of the palette were a key and the button that opened it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task APressOnTheWashClosesThePalette()
|
||||
{
|
||||
await OnThePaletteAsync((_, window) =>
|
||||
{
|
||||
// The bottom-left corner: the card is 520 wide, centred, and starts 90 pixels down, so nothing
|
||||
// here belongs to it.
|
||||
window.MouseDown(new Point(12, 520), MouseButton.Left);
|
||||
|
||||
shell.IsSearching.ShouldBeFalse();
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other half of the same rule, and the one that makes it worth a handler rather than a press anywhere
|
||||
/// closing: a press on the card bubbles through the wash on its way out, so a handler that did not check
|
||||
/// where the press started would close the palette the moment somebody clicked into the box.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task APressOnTheCardDoesNotClose()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
window.MouseDown(Centre(palette.QueryBox, window), MouseButton.Left);
|
||||
|
||||
shell.IsSearching.ShouldBeTrue();
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EscapeClosesThePalette()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
palette.QueryBox.Focus().ShouldBeTrue();
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||
|
||||
shell.IsSearching.ShouldBeFalse();
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The second assertion is the whole reason the selection is moved by hand rather than by letting the list
|
||||
/// take focus: a palette whose arrow keys moved the caret out of the query box would stop receiving the
|
||||
/// next character typed.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheArrowsMoveTheSelectionAndLeaveTheKeyboardInTheBox()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
palette.QueryBox.Focus().ShouldBeTrue();
|
||||
|
||||
shell.SearchResults.Count.ShouldBeGreaterThan(2, "an empty list would prove nothing");
|
||||
shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]);
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None);
|
||||
shell.SelectedSearchResult.ShouldBe(shell.SearchResults[1]);
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
|
||||
shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]);
|
||||
|
||||
// Clamped rather than wrapped, which is the palette's own rule.
|
||||
window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
|
||||
shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]);
|
||||
|
||||
palette.QueryBox.IsFocused.ShouldBeTrue("the arrows must not move the caret out of the box");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// What Enter promises is to take the highlighted row: the palette closes and the vault is pointed at that
|
||||
/// host. The connection it then asks for fails in this suite — there is no server and no shell — and it
|
||||
/// fails inside the vault's own handling, which is the point of connecting through the vault's command
|
||||
/// rather than opening a session from the palette.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task EnterTakesTheHighlightedResult()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
palette.QueryBox.Focus().ShouldBeTrue();
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None);
|
||||
var highlighted = shell.SelectedSearchResult.ShouldNotBeNull();
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
|
||||
shell.IsSearching.ShouldBeFalse();
|
||||
vault.SelectedHost?.EntityId.ShouldBe(highlighted.EntityId);
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The palette is a box somebody is expected to start typing into, and for a while it was not: the window
|
||||
/// focused it from the view model's <c>PropertyChanged</c>, which runs before the binding that reveals the
|
||||
/// control, and <c>Focus()</c> on a collapsed control is a no-op that is never replayed. Becoming visible
|
||||
/// is the moment that cannot be too early, so that is where the palette takes the keyboard — and this is
|
||||
/// the test that says so.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePaletteTakesTheKeyboardWhenItAppears()
|
||||
{
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var elsewhere = new TextBox();
|
||||
var palette = new QuickConnect { DataContext = shell, IsVisible = false };
|
||||
|
||||
var window = new Window { Content = new Panel { Children = { elsewhere, palette } } };
|
||||
LayoutHarness.Settle(window, 900, 600);
|
||||
|
||||
try
|
||||
{
|
||||
elsewhere.Focus().ShouldBeTrue();
|
||||
|
||||
shell.ToggleSearchCommand.Execute(null);
|
||||
palette.IsVisible = true;
|
||||
|
||||
// The layout pass the application's dispatcher would run anyway. Without it the query box
|
||||
// is not in the visual tree yet, which is the whole reason the palette defers this.
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
palette.QueryBox.IsFocused.ShouldBeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// <summary>Opens the palette in a window the size the application's is, and runs one body against it.</summary>
|
||||
private Task OnThePaletteAsync(Action<QuickConnect, Window> body) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
shell.ToggleSearchCommand.Execute(null);
|
||||
shell.IsSearching.ShouldBeTrue("every case here starts with the palette open");
|
||||
|
||||
var palette = new QuickConnect { DataContext = shell };
|
||||
var window = new Window { Content = palette };
|
||||
LayoutHarness.Settle(window, 900, 600);
|
||||
|
||||
try
|
||||
{
|
||||
body(palette, window);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
private static Point Centre(Visual control, Visual window) =>
|
||||
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
|
||||
?? throw new InvalidOperationException("the control is not in this window's tree");
|
||||
|
||||
/// <remarks>Enough hosts that the arrow keys have somewhere to go.</remarks>
|
||||
private async Task SeedAsync()
|
||||
{
|
||||
for (var i = 0; i < 6; i++)
|
||||
{
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = $"host-{i}";
|
||||
vault.EditorHostname = $"host-{i}.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user